detached 4c93f21

refactor(browser): implement native popup lifecycle in workspace

Major refactoring to use WebKit's create/ready-to-show/close signals:

- setupPopupHandling(): connects to WebKit create signal
- handlePopupReadyToShow(): inserts pane when WebKit ready
- handlePopupClose(): responds to WebKit close signal
- Remove deprecated HandlePopup() and manual lifecycle code
- Replace paneDeduplicator with pendingPopups map
- Extract syncPanedDivider() helper for paned position syncing
- Add popupID field to paneNode for lifecycle tracking

This eliminates SIGSEGV crashes by respecting WebKit's popup
lifecycle instead of bypassing it with manual creation.
commit
4c93f210aee493f594ad17153acbe4b72825b25f
author
bamen · · 2025-10-12 07:05 UTC
parent
504ed69
signature
unsigned
5 files · +457 -687
M internal/app/browser/webview.go +17 -14
@@ -200,21 +200,14 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
200200 pane.messageHandler.SetNavigationController(pane.navigationController)
201201 }
202202
203- pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
204- if app.workspace != nil {
205- return app.workspace.HandlePopup(pane.webView, uri)
206- }
207-
208- // No workspace manager - just navigate in current pane
209- if uri != "" && pane.navigationController != nil {
210- go func() {
211- if err := pane.navigationController.NavigateToURL(uri); err != nil {
212- log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
213- }
214- }()
203+ // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
204+ if app.workspace != nil {
205+ node := app.workspace.GetNodeForWebView(pane.webView)
206+ if node != nil {
207+ app.workspace.setupPopupHandling(pane.webView, node)
208+ log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID())
215209 }
216- return nil
217- })
210+ }
218211 }
219212
220213 // createWebView creates and configures the WebView
@@ -252,6 +245,16 @@ func (app *BrowserApp) createWebView() error {
252245 // CRITICAL: Register navigation handler for the root webview
253246 // This is needed because buildPane() couldn't register it when workspace was nil
254247 app.workspace.RegisterNavigationHandler(view)
248+
249+ // CRITICAL: Setup popup handling for the root webview
250+ // This must be done after workspace is created so WebKit's create signal is connected
251+ node := app.workspace.GetNodeForWebView(view)
252+ if node != nil {
253+ app.workspace.setupPopupHandling(view, node)
254+ log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
255+ } else {
256+ log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID())
257+ }
255258 }
256259
257260 // Initialize window-level global shortcuts AFTER workspace is set up
@@ -200,21 +200,14 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
200 pane.messageHandler.SetNavigationController(pane.navigationController)200 pane.messageHandler.SetNavigationController(pane.navigationController)
201 }201 }
202 202
203- pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {203+ // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
204- if app.workspace != nil {204+ if app.workspace != nil {
205- return app.workspace.HandlePopup(pane.webView, uri)205+ node := app.workspace.GetNodeForWebView(pane.webView)
206- }206+ if node != nil {
207-207+ app.workspace.setupPopupHandling(pane.webView, node)
208- // No workspace manager - just navigate in current pane208+ log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID())
209- if uri != "" && pane.navigationController != nil {
210- go func() {
211- if err := pane.navigationController.NavigateToURL(uri); err != nil {
212- log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
213- }
214- }()
215 }209 }
216- return nil210+ }
217- })
218 }211 }
219 212
220 // createWebView creates and configures the WebView213 // createWebView creates and configures the WebView
@@ -252,6 +245,16 @@ func (app *BrowserApp) createWebView() error {
252 // CRITICAL: Register navigation handler for the root webview245 // CRITICAL: Register navigation handler for the root webview
253 // This is needed because buildPane() couldn't register it when workspace was nil246 // This is needed because buildPane() couldn't register it when workspace was nil
254 app.workspace.RegisterNavigationHandler(view)247 app.workspace.RegisterNavigationHandler(view)
248+
249+ // CRITICAL: Setup popup handling for the root webview
250+ // This must be done after workspace is created so WebKit's create signal is connected
251+ node := app.workspace.GetNodeForWebView(view)
252+ if node != nil {
253+ app.workspace.setupPopupHandling(view, node)
254+ log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
255+ } else {
256+ log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID())
257+ }
255 }258 }
256 259
257 // Initialize window-level global shortcuts AFTER workspace is set up260 // Initialize window-level global shortcuts AFTER workspace is set up
M internal/app/browser/workspace_manager.go +11 -63
@@ -43,7 +43,8 @@ type WorkspaceManager struct {
4343 lastStackOperation time.Time // When a stack operation was last performed
4444
4545 // NEW: Pane creation deduplicator
46- paneDeduplicator *messaging.PaneRequestDeduplicator
46+ // Popups waiting for ready-to-show lifecycle callback
47+ pendingPopups map[uint64]*pendingPopup
4748
4849 // Specialized managers for different pane operations
4950 stackedPaneManager *StackedPaneManager
@@ -77,14 +78,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
7778 debugLevel := getDebugLevel()
7879
7980 manager := &WorkspaceManager{
80- app: app,
81- window: rootPane.webView.Window(),
82- viewToNode: make(map[*webkit.WebView]*paneNode),
83- lastSplitMsg: make(map[*webkit.WebView]time.Time),
84- lastExitMsg: make(map[*webkit.WebView]time.Time),
85- paneDeduplicator: messaging.NewPaneRequestDeduplicator(),
86- focusDebounce: 150 * time.Millisecond,
87- debugLevel: debugLevel,
81+ app: app,
82+ window: rootPane.webView.Window(),
83+ viewToNode: make(map[*webkit.WebView]*paneNode),
84+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
85+ lastExitMsg: make(map[*webkit.WebView]time.Time),
86+ pendingPopups: make(map[uint64]*pendingPopup),
87+ focusDebounce: 150 * time.Millisecond,
88+ debugLevel: debugLevel,
8889 }
8990
9091 // Initialize validation components (opt-in based on debug level)
@@ -325,60 +326,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
325326 }
326327 wm.clonePaneState(node, newNode)
327328 case "create-pane":
328- log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
329-
330- if msg.URL == "" {
331- log.Printf("[workspace] create-pane: empty URL, ignoring")
332- break
333- }
334-
335- // NEW: Get WebView ID for deduplication
336- webViewID := "unknown"
337- if source != nil {
338- // Try to get a unique identifier for the WebView
339- webViewID = fmt.Sprintf("%p", source)
340- }
341-
342- // NEW: Create intent for deduplication check
343- intent := &messaging.WindowIntent{
344- URL: msg.URL,
345- WindowType: msg.Action,
346- Timestamp: time.Now().UnixMilli(),
347- RequestID: msg.RequestID,
348- UserTriggered: true,
349- }
350-
351- // NEW: Check for duplicates
352- if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
353- log.Printf("[workspace] create-pane BLOCKED: %s", reason)
354- break
355- }
356-
357- // Use the existing methods to handle tab vs popup creation
358- switch strings.ToLower(msg.Action) {
359- case "tab":
360- newView := wm.handleIntentAsTab(node, msg.URL, intent)
361- if newView != nil {
362- log.Printf("[workspace] create-pane: tab created successfully")
363- } else {
364- log.Printf("[workspace] create-pane: failed to create tab")
365- }
366- case "popup":
367- newView := wm.handleIntentAsPopup(node, msg.URL, intent)
368- if newView != nil {
369- log.Printf("[workspace] create-pane: popup created successfully")
370- } else {
371- log.Printf("[workspace] create-pane: failed to create popup")
372- }
373- default:
374- log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
375- newView := wm.handleIntentAsTab(node, msg.URL, intent)
376- if newView != nil {
377- log.Printf("[workspace] create-pane: default tab created successfully")
378- } else {
379- log.Printf("[workspace] create-pane: failed to create default tab")
380- }
381- }
329+ log.Printf("[workspace] create-pane message ignored (native popup lifecycle enabled)")
382330 case "close-popup":
383331 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
384332
@@ -43,7 +43,8 @@ type WorkspaceManager struct {
43 lastStackOperation time.Time // When a stack operation was last performed43 lastStackOperation time.Time // When a stack operation was last performed
44 44
45 // NEW: Pane creation deduplicator45 // NEW: Pane creation deduplicator
46- paneDeduplicator *messaging.PaneRequestDeduplicator46+ // Popups waiting for ready-to-show lifecycle callback
47+ pendingPopups map[uint64]*pendingPopup
47 48
48 // Specialized managers for different pane operations49 // Specialized managers for different pane operations
49 stackedPaneManager *StackedPaneManager50 stackedPaneManager *StackedPaneManager
@@ -77,14 +78,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
77 debugLevel := getDebugLevel()78 debugLevel := getDebugLevel()
78 79
79 manager := &WorkspaceManager{80 manager := &WorkspaceManager{
80- app: app,81+ app: app,
81- window: rootPane.webView.Window(),82+ window: rootPane.webView.Window(),
82- viewToNode: make(map[*webkit.WebView]*paneNode),83+ viewToNode: make(map[*webkit.WebView]*paneNode),
83- lastSplitMsg: make(map[*webkit.WebView]time.Time),84+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
84- lastExitMsg: make(map[*webkit.WebView]time.Time),85+ lastExitMsg: make(map[*webkit.WebView]time.Time),
85- paneDeduplicator: messaging.NewPaneRequestDeduplicator(),86+ pendingPopups: make(map[uint64]*pendingPopup),
86- focusDebounce: 150 * time.Millisecond,87+ focusDebounce: 150 * time.Millisecond,
87- debugLevel: debugLevel,88+ debugLevel: debugLevel,
88 }89 }
89 90
90 // Initialize validation components (opt-in based on debug level)91 // Initialize validation components (opt-in based on debug level)
@@ -325,60 +326,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
325 }326 }
326 wm.clonePaneState(node, newNode)327 wm.clonePaneState(node, newNode)
327 case "create-pane":328 case "create-pane":
328- log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)329+ log.Printf("[workspace] create-pane message ignored (native popup lifecycle enabled)")
329-
330- if msg.URL == "" {
331- log.Printf("[workspace] create-pane: empty URL, ignoring")
332- break
333- }
334-
335- // NEW: Get WebView ID for deduplication
336- webViewID := "unknown"
337- if source != nil {
338- // Try to get a unique identifier for the WebView
339- webViewID = fmt.Sprintf("%p", source)
340- }
341-
342- // NEW: Create intent for deduplication check
343- intent := &messaging.WindowIntent{
344- URL: msg.URL,
345- WindowType: msg.Action,
346- Timestamp: time.Now().UnixMilli(),
347- RequestID: msg.RequestID,
348- UserTriggered: true,
349- }
350-
351- // NEW: Check for duplicates
352- if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
353- log.Printf("[workspace] create-pane BLOCKED: %s", reason)
354- break
355- }
356-
357- // Use the existing methods to handle tab vs popup creation
358- switch strings.ToLower(msg.Action) {
359- case "tab":
360- newView := wm.handleIntentAsTab(node, msg.URL, intent)
361- if newView != nil {
362- log.Printf("[workspace] create-pane: tab created successfully")
363- } else {
364- log.Printf("[workspace] create-pane: failed to create tab")
365- }
366- case "popup":
367- newView := wm.handleIntentAsPopup(node, msg.URL, intent)
368- if newView != nil {
369- log.Printf("[workspace] create-pane: popup created successfully")
370- } else {
371- log.Printf("[workspace] create-pane: failed to create popup")
372- }
373- default:
374- log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
375- newView := wm.handleIntentAsTab(node, msg.URL, intent)
376- if newView != nil {
377- log.Printf("[workspace] create-pane: default tab created successfully")
378- } else {
379- log.Printf("[workspace] create-pane: failed to create default tab")
380- }
381- }
382 case "close-popup":330 case "close-popup":
383 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)331 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
384 332
M internal/app/browser/workspace_pane_ops.go +79 -42
@@ -6,6 +6,7 @@ import (
66 "log"
77
88 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
910 "github.com/diamondburned/gotk4/pkg/gtk/v4"
1011 )
1112
@@ -497,27 +498,8 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
497498 return false
498499 }, split)
499500
500- // CRITICAL: Set initial 50/50 split position after showing paned
501- // GTK needs the widget to be realized before we can set position based on allocation
502- // Schedule this to run after GTK has allocated space to the paned
503- wm.scheduleIdleGuarded(func() bool {
504- if paned == nil {
505- return false
506- }
507- // Get paned allocation to calculate 50% position
508- alloc := paned.Allocation()
509- var splitPos int
510- if orientation == gtk.OrientationHorizontal {
511- splitPos = alloc.Width() / 2
512- } else {
513- splitPos = alloc.Height() / 2
514- }
515- if splitPos > 0 {
516- paned.SetPosition(splitPos)
517- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width(), alloc.Height())
518- }
519- return false
520- }, split)
501+ // Synchronize the divider once the paned has a meaningful allocation.
502+ wm.syncPanedDivider(paned, orientation, split)
521503
522504 // CRITICAL: Show the split target container and force GTK to recreate rendering surface after reparenting
523505 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
@@ -785,27 +767,8 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
785767
786768 paned.Show()
787769
788- // CRITICAL: Set initial 50/50 split position after showing paned
789- // GTK needs the widget to be realized before we can set position based on allocation
790- // Schedule this to run after GTK has allocated space to the paned
791- wm.scheduleIdleGuarded(func() bool {
792- if paned == nil {
793- return false
794- }
795- // Get paned allocation to calculate 50% position
796- alloc := paned.Allocation()
797- var splitPos int
798- if orientation == gtk.OrientationHorizontal {
799- splitPos = alloc.Width() / 2
800- } else {
801- splitPos = alloc.Height() / 2
802- }
803- if splitPos > 0 {
804- paned.SetPosition(splitPos)
805- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width(), alloc.Height())
806- }
807- return false
808- }, split)
770+ // Synchronize the divider once the paned has a meaningful allocation.
771+ wm.syncPanedDivider(paned, orientation, split)
809772
810773 // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
811774 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
@@ -1381,3 +1344,77 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
13811344
13821345 return stack, nil
13831346 }
1347+
1348+// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1349+// the divider to an even split. GTK often reports near-zero allocations for a few frames
1350+// after insertion, so we retry on map/tick/idle until the size stabilizes.
1351+func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1352+ if wm == nil || paned == nil {
1353+ return
1354+ }
1355+
1356+ const (
1357+ minDimension = 32
1358+ maxFrames = 120
1359+ )
1360+
1361+ setPosition := func() bool {
1362+ if guard != nil && !guard.widgetValid {
1363+ return true
1364+ }
1365+
1366+ alloc := paned.Allocation()
1367+ dimension := alloc.Width()
1368+ if orientation == gtk.OrientationVertical {
1369+ dimension = alloc.Height()
1370+ }
1371+
1372+ if dimension < minDimension {
1373+ return false
1374+ }
1375+
1376+ pos := dimension / 2
1377+ if pos <= 0 {
1378+ return false
1379+ }
1380+
1381+ paned.SetPosition(pos)
1382+ log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1383+ return true
1384+ }
1385+
1386+ // Attempt immediately with the current allocation.
1387+ if setPosition() {
1388+ return
1389+ }
1390+
1391+ var frames uint
1392+
1393+ paned.ConnectMap(func() {
1394+ setPosition()
1395+ })
1396+
1397+ paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1398+ if guard != nil && !guard.widgetValid {
1399+ return false
1400+ }
1401+
1402+ frames++
1403+ if setPosition() {
1404+ return false
1405+ }
1406+
1407+ if frames >= maxFrames {
1408+ alloc := paned.Allocation()
1409+ log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1410+ return false
1411+ }
1412+
1413+ return true
1414+ })
1415+
1416+ wm.scheduleIdleGuarded(func() bool {
1417+ setPosition()
1418+ return false
1419+ }, guard)
1420+}
@@ -6,6 +6,7 @@ import (
6 "log"6 "log"
7 7
8 "github.com/bnema/dumber/pkg/webkit"8 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
9 "github.com/diamondburned/gotk4/pkg/gtk/v4"10 "github.com/diamondburned/gotk4/pkg/gtk/v4"
10 )11 )
11 12
@@ -497,27 +498,8 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
497 return false498 return false
498 }, split)499 }, split)
499 500
500- // CRITICAL: Set initial 50/50 split position after showing paned501+ // Synchronize the divider once the paned has a meaningful allocation.
501- // GTK needs the widget to be realized before we can set position based on allocation502+ wm.syncPanedDivider(paned, orientation, split)
502- // Schedule this to run after GTK has allocated space to the paned
503- wm.scheduleIdleGuarded(func() bool {
504- if paned == nil {
505- return false
506- }
507- // Get paned allocation to calculate 50% position
508- alloc := paned.Allocation()
509- var splitPos int
510- if orientation == gtk.OrientationHorizontal {
511- splitPos = alloc.Width() / 2
512- } else {
513- splitPos = alloc.Height() / 2
514- }
515- if splitPos > 0 {
516- paned.SetPosition(splitPos)
517- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width(), alloc.Height())
518- }
519- return false
520- }, split)
521 503
522 // CRITICAL: Show the split target container and force GTK to recreate rendering surface after reparenting504 // CRITICAL: Show the split target container and force GTK to recreate rendering surface after reparenting
523 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues505 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
@@ -785,27 +767,8 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
785 767
786 paned.Show()768 paned.Show()
787 769
788- // CRITICAL: Set initial 50/50 split position after showing paned770+ // Synchronize the divider once the paned has a meaningful allocation.
789- // GTK needs the widget to be realized before we can set position based on allocation771+ wm.syncPanedDivider(paned, orientation, split)
790- // Schedule this to run after GTK has allocated space to the paned
791- wm.scheduleIdleGuarded(func() bool {
792- if paned == nil {
793- return false
794- }
795- // Get paned allocation to calculate 50% position
796- alloc := paned.Allocation()
797- var splitPos int
798- if orientation == gtk.OrientationHorizontal {
799- splitPos = alloc.Width() / 2
800- } else {
801- splitPos = alloc.Height() / 2
802- }
803- if splitPos > 0 {
804- paned.SetPosition(splitPos)
805- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width(), alloc.Height())
806- }
807- return false
808- }, split)
809 772
810 // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting773 // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
811 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues774 // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
@@ -1381,3 +1344,77 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1381 1344
1382 return stack, nil1345 return stack, nil
1383 }1346 }
1347+
1348+// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1349+// the divider to an even split. GTK often reports near-zero allocations for a few frames
1350+// after insertion, so we retry on map/tick/idle until the size stabilizes.
1351+func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1352+ if wm == nil || paned == nil {
1353+ return
1354+ }
1355+
1356+ const (
1357+ minDimension = 32
1358+ maxFrames = 120
1359+ )
1360+
1361+ setPosition := func() bool {
1362+ if guard != nil && !guard.widgetValid {
1363+ return true
1364+ }
1365+
1366+ alloc := paned.Allocation()
1367+ dimension := alloc.Width()
1368+ if orientation == gtk.OrientationVertical {
1369+ dimension = alloc.Height()
1370+ }
1371+
1372+ if dimension < minDimension {
1373+ return false
1374+ }
1375+
1376+ pos := dimension / 2
1377+ if pos <= 0 {
1378+ return false
1379+ }
1380+
1381+ paned.SetPosition(pos)
1382+ log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1383+ return true
1384+ }
1385+
1386+ // Attempt immediately with the current allocation.
1387+ if setPosition() {
1388+ return
1389+ }
1390+
1391+ var frames uint
1392+
1393+ paned.ConnectMap(func() {
1394+ setPosition()
1395+ })
1396+
1397+ paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1398+ if guard != nil && !guard.widgetValid {
1399+ return false
1400+ }
1401+
1402+ frames++
1403+ if setPosition() {
1404+ return false
1405+ }
1406+
1407+ if frames >= maxFrames {
1408+ alloc := paned.Allocation()
1409+ log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1410+ return false
1411+ }
1412+
1413+ return true
1414+ })
1415+
1416+ wm.scheduleIdleGuarded(func() bool {
1417+ setPosition()
1418+ return false
1419+ }, guard)
1420+}
M internal/app/browser/workspace_popup.go +322 -551
@@ -2,13 +2,14 @@
22 package browser
33
44 import (
5- "fmt"
65 "log"
76 "strings"
87 "time"
98
10- "github.com/bnema/dumber/internal/app/messaging"
9+ "github.com/bnema/dumber/internal/config"
1110 "github.com/bnema/dumber/pkg/webkit"
11+ webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/glib/v2"
1213 )
1314
1415 const (
@@ -17,264 +18,9 @@ const (
1718 defaultPopupMinHeight = 600 // Minimum height for OAuth popups
1819 )
1920
20-// HandlePopup handles popup window creation requests from WebViews
21-func (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- // Popups are embedded in workspace paned containers - no separate window needed
74- webkitCfg.CreateWindow = false
75- // Create as related to avoid WindowFeatures crash; we'll decide final placement later
76- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
77- if err != nil {
78- log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
79- return nil
80- }
81-
82- // Register detection callback
83- newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
84- wm.RunOnUI(func() {
85- wm.handleDetectedWindowType(node, newView, url, t, feat)
86- })
87- })
88-
89- // Fallback: if detection never fires, treat as popup as before
90- go func() {
91- time.Sleep(1500 * time.Millisecond)
92- if newView != nil {
93- wm.RunOnUI(func() {
94- if wm.viewToNode[newView] == nil { // not yet placed
95- wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
96- }
97- })
98- }
99- }()
100-
101- return newView
102- }
103-
104- // Legacy path preserved
105- webkitCfg, err := wm.app.buildWebkitConfig()
106- if err != nil {
107- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
108- return nil
109- }
110- // Popups are embedded in workspace paned containers - no separate window needed
111- webkitCfg.CreateWindow = false
112- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
113- if err != nil {
114- log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
115- return nil
116- }
117-
118- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
119-
120- // Create a pane for the new WebView
121- newPane, err := wm.createPane(newView)
122- if err != nil {
123- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
124- return nil
125- }
126-
127- // Determine placement direction
128- direction := strings.ToLower(popCfg.Placement)
129- if direction == "" {
130- direction = "right"
131- }
132-
133- // Determine target node for splitting
134- target := node
135- if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
136- target = wm.GetActiveNode()
137- }
138-
139- // Add the popup pane to the workspace using manual pane insertion
140- if err := wm.insertPopupPane(target, newPane, direction); err != nil {
141- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
142- return nil
143- }
144-
145- // Get the new node and popup WebView ID for tracking
146- newNode := wm.viewToNode[newView]
147- popupWebViewID := newView.IDString()
148-
149- // Apply different behavior based on target type
150- if isBlankTarget {
151- log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
152- // For _blank targets, set up parent relationship but no auto-close
153- if newNode != nil {
154- newNode.parentPane = node
155- }
156-
157- // Register close handler to clean up activePopupChildren tracking
158- newView.RegisterCloseHandler(func() {
159- log.Printf("[workspace] _blank popup requesting close via window.close()")
160- // Remove this popup from parent's activePopupChildren
161- if node != nil && popupWebViewID != "" {
162- for i, childID := range node.activePopupChildren {
163- if childID == popupWebViewID {
164- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
165- log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
166- break
167- }
168- }
169- }
170- })
171- } else {
172- log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
173- // Mark as popup for auto-close handling (OAuth flows, etc.)
174- if newNode != nil {
175- newNode.isPopup = true
176- newNode.parentPane = node
177- log.Printf("[workspace] Marked pane as popup for auto-close handling")
178-
179- // Register close handler for popup auto-close on window.close()
180- newView.RegisterCloseHandler(func() {
181- log.Printf("[workspace] Popup requesting close via window.close()")
182-
183- // Remove this popup from parent's activePopupChildren
184- if node != nil && popupWebViewID != "" {
185- for i, childID := range node.activePopupChildren {
186- if childID == popupWebViewID {
187- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
188- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
189- break
190- }
191- }
192- }
193-
194- // Look up the node at close time
195- if node := wm.viewToNode[newView]; node != nil && node.isPopup {
196- log.Printf("[workspace] Closing popup pane")
197- // Brief delay to allow any final redirects to complete
198- time.AfterFunc(200*time.Millisecond, func() {
199- wm.scheduleIdleGuarded(func() bool {
200- if node == nil || !node.widgetValid {
201- return false
202- }
203- if err := wm.ClosePane(node); err != nil {
204- log.Printf("[workspace] Failed to close popup pane: %v", err)
205- }
206- return false
207- }, node)
208- })
209- } else {
210- log.Printf("[workspace] Could not find popup node for close handler")
211- }
212- })
213- } else {
214- log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
215- }
216- }
217-
218- // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
219- // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
220- if node != nil && popupWebViewID != "" {
221- if node.activePopupChildren == nil {
222- node.activePopupChildren = make([]string, 0)
223- }
224- node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
225- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
226-
227- // Register navigation policy handler for parent to block script-initiated navigations
228- // when it has active popup children (prevents OAuth from hijacking parent pane)
229- if node.pane != nil && node.pane.WebView() != nil {
230- node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
231- // Always allow user-initiated navigations
232- if isUserGesture {
233- return true
234- }
235-
236- // Block script-initiated navigations if this pane has active popup children
237- if len(node.activePopupChildren) > 0 {
238- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
239- return false
240- }
241-
242- // Allow if no active popups
243- return true
244- })
245- log.Printf("[workspace] Registered navigation policy handler for parent pane")
246- }
247- }
248-
249- // Ensure GUI components are available in the new pane
250- wm.ensureGUIInPane(newPane)
251-
252- // Inject GUI components into the popup pane
253- wm.ensureGUIInPane(newPane)
254-
255- // Load the URL if provided
256- if url != "" {
257- paneType := "popup"
258- if isBlankTarget {
259- paneType = "_blank target"
260- }
261- log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
262- if err := newView.LoadURL(url); err != nil {
263- log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
264- }
265- // Ensure the WebView is visible after loading URL
266- if err := newView.Show(); err != nil {
267- log.Printf("[workspace] failed to show popup WebView: %v", err)
268- }
269- }
270-
271- if isBlankTarget {
272- log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
273- } else {
274- log.Printf("[workspace] Created popup pane for URL: %s", url)
275- }
276- return newView
277-}
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.
27824
27925 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups
28026 // Note: OAuth detection is now handled by the main-world.js injection script
@@ -283,296 +29,6 @@ func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url str
28329 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
28430 }
28531
286-// applyWindowFeatures applies window features to a WebView based on intent
287-func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
288- if intent == nil {
289- return
290- }
291-
292- features := &webkit.WindowFeatures{}
293-
294- // Apply dimensions if specified
295- if intent.Width != nil {
296- features.Width = *intent.Width
297- }
298- if intent.Height != nil {
299- features.Height = *intent.Height
300- }
301-
302- // Apply visibility features based on window type
303- defaultToolbar := !isPopup
304- defaultLocation := !isPopup
305- defaultMenubar := !isPopup
306-
307- if intent.Toolbar != nil {
308- features.ToolbarVisible = *intent.Toolbar
309- } else {
310- features.ToolbarVisible = defaultToolbar
311- }
312-
313- if intent.Location != nil {
314- features.LocationbarVisible = *intent.Location
315- } else {
316- features.LocationbarVisible = defaultLocation
317- }
318-
319- if intent.Menubar != nil {
320- features.MenubarVisible = *intent.Menubar
321- } else {
322- features.MenubarVisible = defaultMenubar
323- }
324-
325- if intent.Resizable != nil {
326- features.Resizable = *intent.Resizable
327- } else {
328- features.Resizable = true // Usually resizable unless explicitly disabled
329- }
330-
331- view.SetWindowFeatures(features)
332- windowTypeStr := "tab"
333- if isPopup {
334- windowTypeStr = "popup"
335- }
336- log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
337- windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
338-}
339-
340-// handleIntentAsTab creates a tab pane based on window.open intent
341-func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
342- log.Printf("[workspace] Handling intent as tab: %s", url)
343-
344- webkitCfg, err := wm.app.buildWebkitConfig()
345- if err != nil {
346- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
347- return nil
348- }
349- // Tabs are embedded in workspace paned containers - no separate window needed
350- webkitCfg.CreateWindow = false
351-
352- newView, err := webkit.NewWebView(webkitCfg)
353- if err != nil {
354- log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
355- return nil
356- }
357-
358- newPane, err := wm.createPane(newView)
359- if err != nil {
360- log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
361- return nil
362- }
363-
364- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
365- if direction == "" {
366- direction = "right"
367- }
368-
369- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
370- log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
371- return nil
372- }
373-
374- node := wm.viewToNode[newView]
375- if node != nil {
376- node.windowType = webkit.WindowTypeTab
377- node.isRelated = false
378-
379- // Apply window features from JavaScript intent
380- wm.applyWindowFeatures(newView, intent, false)
381- }
382-
383- wm.ensureGUIInPane(newPane)
384-
385- if url != "" {
386- if err := newView.LoadURL(url); err != nil {
387- log.Printf("[workspace] failed to load tab URL: %v", err)
388- }
389- if err := newView.Show(); err != nil {
390- log.Printf("[workspace] failed to show tab WebView: %v", err)
391- }
392- }
393-
394- log.Printf("[workspace] Created tab pane for URL: %s", url)
395- return newView
396-}
397-
398-// handleIntentAsPopup creates a related popup pane based on window.open intent
399-func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
400- log.Printf("[workspace] Handling intent as popup: %s", url)
401-
402- webkitCfg, err := wm.app.buildWebkitConfig()
403- if err != nil {
404- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
405- return nil
406- }
407- // Popups are embedded in workspace paned containers - no separate window needed
408- webkitCfg.CreateWindow = false
409-
410- newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
411- if err != nil {
412- log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
413- return nil
414- }
415-
416- // Log the parent-popup WebView ID relationship for OAuth auto-close
417- parentWebViewID := sourceNode.pane.webView.IDString()
418- popupWebViewID := newView.IDString()
419- log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
420-
421- // Store popup WebView ID in parent's localStorage for OAuth callback lookup
422- storeScript := fmt.Sprintf(`
423- try {
424- const parentWebViewId = '%s';
425- const popupWebViewId = '%s';
426- const popupMapping = {
427- parentId: parentWebViewId,
428- popupId: popupWebViewId,
429- timestamp: Date.now(),
430- url: '%s'
431- };
432- localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
433- console.log('[workspace] Stored popup mapping:', popupMapping);
434- } catch(e) {
435- console.warn('[workspace] Failed to store popup mapping:', e);
436- }
437- `, parentWebViewID, popupWebViewID, url)
438-
439- // Inject into parent WebView so it can find its popup later
440- if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
441- log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
442- }
443-
444- newPane, err := wm.createPane(newView)
445- if err != nil {
446- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
447- return nil
448- }
449-
450- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
451- if direction == "" {
452- direction = "right"
453- }
454-
455- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
456- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
457- return nil
458- }
459-
460- node := wm.viewToNode[newView]
461- var requestID string
462- if node != nil {
463- node.windowType = webkit.WindowTypePopup
464- node.isRelated = true
465- node.parentPane = sourceNode
466- node.isPopup = true
467- node.autoClose = wm.shouldAutoClose(url)
468-
469- // Track this popup in the parent's activePopupChildren
470- if sourceNode != nil {
471- if sourceNode.activePopupChildren == nil {
472- sourceNode.activePopupChildren = make([]string, 0)
473- }
474- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
475- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
476-
477- // Register navigation policy handler for parent to block script-initiated navigations
478- // when it has active popup children (prevents OAuth from hijacking parent pane)
479- if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
480- sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
481- // Always allow user-initiated navigations
482- if isUserGesture {
483- return true
484- }
485-
486- // Block script-initiated navigations if this pane has active popup children
487- if len(sourceNode.activePopupChildren) > 0 {
488- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
489- return false
490- }
491-
492- // Allow if no active popups
493- return true
494- })
495- log.Printf("[workspace] Registered navigation policy handler for parent pane")
496- }
497- }
498-
499- // Store requestID for deduplication cleanup
500- if intent != nil {
501- requestID = intent.RequestID
502- node.requestID = requestID
503- }
504-
505- // Apply window features from JavaScript intent
506- wm.applyWindowFeatures(newView, intent, true)
507-
508- // Apply minimum size constraints to prevent compression
509- var width, height int
510- if intent != nil {
511- if intent.Width != nil {
512- width = *intent.Width
513- }
514- if intent.Height != nil {
515- height = *intent.Height
516- }
517- }
518- wm.applyPopupSizeConstraints(newView, width, height)
519- }
520-
521- // Register close handler for popup auto-close
522- newView.RegisterCloseHandler(func() {
523- log.Printf("[workspace] Popup requesting close via window.close()")
524-
525- // Clear the RequestID from deduplicator to allow new popups with same ID
526- if requestID != "" && wm.paneDeduplicator != nil {
527- wm.paneDeduplicator.ClearRequestID(requestID)
528- }
529-
530- // Remove this popup from parent's activePopupChildren
531- if sourceNode != nil && popupWebViewID != "" {
532- for i, childID := range sourceNode.activePopupChildren {
533- if childID == popupWebViewID {
534- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
535- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
536- break
537- }
538- }
539- }
540-
541- if n := wm.viewToNode[newView]; n != nil && n.isPopup {
542- time.AfterFunc(200*time.Millisecond, func() {
543- wm.scheduleIdleGuarded(func() bool {
544- if n == nil || !n.widgetValid {
545- return false
546- }
547- if err := wm.ClosePane(n); err != nil {
548- log.Printf("[workspace] Failed to close popup pane: %v", err)
549- }
550- return false
551- }, n)
552- })
553- }
554- })
555-
556- // URL-based auto-close for OAuth popups
557- if node != nil && node.isPopup && node.autoClose {
558- wm.registerOAuthAutoClose(newView, url)
559- }
560-
561- wm.ensureGUIInPane(newPane)
562-
563- if url != "" {
564- if err := newView.LoadURL(url); err != nil {
565- log.Printf("[workspace] failed to load popup URL: %v", err)
566- }
567- if err := newView.Show(); err != nil {
568- log.Printf("[workspace] failed to show popup WebView: %v", err)
569- }
570- }
571-
572- log.Printf("[workspace] Created popup pane for URL: %s", url)
573- return newView
574-}
575-
57632 // insertIndependentPane inserts a new independent pane next to the source
57733 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
57834 newPane, err := wm.createPane(webView)
@@ -803,7 +259,7 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
803259 // Tabs are embedded in workspace paned containers - no separate window needed
804260 webkitCfg.CreateWindow = false
805261
806- // Create independent WebView like handleIntentAsTab does
262+ // Create independent WebView matching our standard tab insertion path
807263 independentView, err := webkit.NewWebView(webkitCfg)
808264 if err != nil {
809265 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
@@ -824,3 +280,318 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
824280 wm.configureRelatedPopup(sourceNode, webView, url, features)
825281 }
826282 }
283+
284+// setupPopupHandling connects WebView's create signal for popup lifecycle management
285+// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
286+func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
287+ if webView == nil || parentNode == nil {
288+ return
289+ }
290+
291+ webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
292+ // Get URL from navigation action
293+ req := navAction.Request()
294+ if req == nil {
295+ log.Printf("[workspace] create signal: no request in navigation action")
296+ return nil
297+ }
298+ url := req.URI()
299+
300+ log.Printf("[workspace] create signal received for URL: %s", url)
301+
302+ // Check if popups are enabled in config
303+ if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
304+ log.Printf("[workspace] Popup creation disabled in config - blocking popup")
305+ return nil
306+ }
307+
308+ // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
309+ // WebKit requires the related-view property to be set during construction for proper lifecycle
310+ // This ensures session/cookie sharing and proper WindowFeatures initialization
311+ // All initialization (wrapping, container, scripts) happens in ready-to-show
312+ log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
313+ barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
314+ if barePopupView == nil {
315+ log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
316+ return nil
317+ }
318+ log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
319+
320+ // Wrap the bare view immediately to get a proper ID
321+ // BUT don't initialize it yet - that happens in ready-to-show
322+ log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
323+ wrappedView := webkit.WrapBareWebView(barePopupView)
324+ if wrappedView == nil {
325+ log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
326+ return nil
327+ }
328+
329+ // Use the wrapper's ID for tracking
330+ popupID := wrappedView.ID()
331+ log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
332+
333+ // Store wrapped view (not initialized) for initialization in ready-to-show
334+ log.Printf("[workspace] Step 3: Storing in pendingPopups map")
335+ wm.pendingPopups[popupID] = &pendingPopup{
336+ wrappedView: wrappedView,
337+ parentView: webView,
338+ parentNode: parentNode,
339+ url: url,
340+ }
341+ log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
342+
343+ // CRITICAL: Do NOT connect signals before returning to WebKit
344+ // Connecting signals might trigger WebKit to access WindowFeatures before it's set
345+ // Instead, we'll connect them after WebKit has processed the return
346+ log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
347+
348+ // Use GLib idle_add to connect signals AFTER this callback returns
349+ // This ensures WebKit has finished configuring WindowFeatures before we touch the view
350+ glib.IdleAdd(func() bool {
351+ log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
352+
353+ // Connect ready-to-show signal NOW (after WebKit has configured the view)
354+ barePopupView.ConnectReadyToShow(func() {
355+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
356+ wm.handlePopupReadyToShow(popupID)
357+ })
358+ log.Printf("[workspace] Idle callback: ready-to-show connected")
359+
360+ // Connect close handler
361+ barePopupView.ConnectClose(func() {
362+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
363+ wm.handlePopupClose(popupID)
364+ })
365+ log.Printf("[workspace] Idle callback: close signal connected")
366+
367+ return false // Don't repeat
368+ })
369+
370+ log.Printf("[workspace] Step 4 complete: signal connections scheduled")
371+
372+ log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
373+
374+ // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
375+ return wrappedView
376+ })
377+}
378+
379+// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
380+func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
381+ info, ok := wm.pendingPopups[popupID]
382+ if !ok {
383+ log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
384+ return
385+ }
386+ delete(wm.pendingPopups, popupID)
387+
388+ log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
389+
390+ // NOW it's safe to initialize (WebKit has configured WindowProperties)
391+ // We already have the wrapped view from setupPopupHandling
392+ wrappedView := info.wrappedView
393+
394+ // Build webkit config for initialization
395+ webkitCfg, err := wm.app.buildWebkitConfig()
396+ if err != nil {
397+ log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
398+ return
399+ }
400+ webkitCfg.CreateWindow = false // Embedded in workspace
401+
402+ // Initialize the wrapped view with full configuration
403+ if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
404+ log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
405+ return
406+ }
407+
408+ log.Printf("[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+ log.Printf("[workspace] Failed to create popup pane: %v", err)
414+ return
415+ }
416+
417+ log.Printf("[workspace] Created popup pane, now inserting into workspace")
418+
419+ // NOW it's safe to insert into workspace based on configured behavior
420+ behavior := wm.app.config.Workspace.Popups.Behavior
421+ log.Printf("[workspace] Popup behavior: %s", behavior)
422+
423+ switch behavior {
424+ case config.PopupBehaviorSplit:
425+ // Split pane behavior (default)
426+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
427+ if direction == "" {
428+ direction = "right"
429+ }
430+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
431+ log.Printf("[workspace] Failed to insert split popup pane: %v", err)
432+ return
433+ }
434+
435+ case config.PopupBehaviorStacked:
436+ // Stacked pane behavior
437+ _, err := wm.stackedPaneManager.StackPane(info.parentNode)
438+ if err != nil {
439+ log.Printf("[workspace] Failed to stack existing pane: %v", err)
440+ return
441+ }
442+ // Insert the popup pane in the same container
443+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
444+ if direction == "" {
445+ direction = "right"
446+ }
447+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
448+ log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
449+ return
450+ }
451+
452+ case config.PopupBehaviorTabbed:
453+ // Tabbed behavior - TODO: implement proper tabbed interface
454+ log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
455+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
456+ if direction == "" {
457+ direction = "right"
458+ }
459+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
460+ log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
461+ return
462+ }
463+
464+ case config.PopupBehaviorWindowed:
465+ // Windowed behavior - opens in new workspace/window
466+ // TODO: implement new window creation with GTK
467+ log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
468+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
469+ if direction == "" {
470+ direction = "right"
471+ }
472+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
473+ log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
474+ return
475+ }
476+
477+ default:
478+ // Fallback to split if unknown behavior
479+ log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
480+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
481+ if direction == "" {
482+ direction = "right"
483+ }
484+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
485+ log.Printf("[workspace] Failed to insert popup pane: %v", err)
486+ return
487+ }
488+ }
489+
490+ // Configure the popup node
491+ node := wm.viewToNode[wrappedView]
492+ if node != nil {
493+ node.windowType = webkit.WindowTypePopup
494+ node.isRelated = true
495+ node.parentPane = info.parentNode
496+ node.isPopup = true
497+ node.autoClose = wm.shouldAutoClose(info.url)
498+ node.popupID = popupID // Store popupID for close signal lookup
499+
500+ // Track this popup in the parent's activePopupChildren
501+ if info.parentNode != nil {
502+ popupWebViewID := wrappedView.IDString()
503+ if info.parentNode.activePopupChildren == nil {
504+ info.parentNode.activePopupChildren = make([]string, 0)
505+ }
506+ info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
507+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
508+ popupWebViewID, len(info.parentNode.activePopupChildren))
509+
510+ // Register navigation policy handler for parent
511+ if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
512+ info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
513+ if isUserGesture {
514+ return true
515+ }
516+ if len(info.parentNode.activePopupChildren) > 0 {
517+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
518+ len(info.parentNode.activePopupChildren), url)
519+ return false
520+ }
521+ return true
522+ })
523+ }
524+ }
525+
526+ // URL-based auto-close for OAuth popups
527+ if node.autoClose {
528+ wm.registerOAuthAutoClose(wrappedView, info.url)
529+ }
530+ }
531+
532+ wm.ensureGUIInPane(popupPane)
533+
534+ // WebKit handles loading the URL automatically, just ensure visibility
535+ if err := wrappedView.Show(); err != nil {
536+ log.Printf("[workspace] failed to show popup WebView: %v", err)
537+ }
538+
539+ log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
540+}
541+
542+// handlePopupClose responds to WebKit's close signal
543+func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
544+ log.Printf("[workspace] close signal received for popup native=%d", popupID)
545+
546+ // Check if it's in pending (not yet shown)
547+ if info, ok := wm.pendingPopups[popupID]; ok {
548+ delete(wm.pendingPopups, popupID)
549+ log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
550+ // Just remove from pending, WebKit will handle cleanup
551+ _ = info // avoid unused variable
552+ return
553+ }
554+
555+ // Find pane by popupID (stored in node during ready-to-show)
556+ var targetNode *paneNode
557+ for _, node := range wm.viewToNode {
558+ if node != nil && node.popupID == popupID {
559+ targetNode = node
560+ break
561+ }
562+ }
563+
564+ if targetNode == nil {
565+ log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
566+ return
567+ }
568+
569+ // Remove from parent's activePopupChildren
570+ // Find the wrapped WebView for this node to get its IDString
571+ var wrappedViewID string
572+ for webView, node := range wm.viewToNode {
573+ if node == targetNode {
574+ wrappedViewID = webView.IDString()
575+ break
576+ }
577+ }
578+
579+ if targetNode.parentPane != nil && wrappedViewID != "" {
580+ for i, childID := range targetNode.parentPane.activePopupChildren {
581+ if childID == wrappedViewID {
582+ targetNode.parentPane.activePopupChildren = append(
583+ targetNode.parentPane.activePopupChildren[:i],
584+ targetNode.parentPane.activePopupChildren[i+1:]...)
585+ log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
586+ len(targetNode.parentPane.activePopupChildren))
587+ break
588+ }
589+ }
590+ }
591+
592+ // Clean close via existing ClosePane (WebKit already stopped)
593+ log.Printf("[workspace] Closing popup pane via ClosePane")
594+ if err := wm.ClosePane(targetNode); err != nil {
595+ log.Printf("[workspace] Error closing popup pane: %v", err)
596+ }
597+}
@@ -2,13 +2,14 @@
2 package browser2 package browser
3 3
4 import (4 import (
5- "fmt"
6 "log"5 "log"
7 "strings"6 "strings"
8 "time"7 "time"
9 8
10- "github.com/bnema/dumber/internal/app/messaging"9+ "github.com/bnema/dumber/internal/config"
11 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11+ webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/glib/v2"
12 )13 )
13 14
14 const (15 const (
@@ -17,264 +18,9 @@ const (
17 defaultPopupMinHeight = 600 // Minimum height for OAuth popups18 defaultPopupMinHeight = 600 // Minimum height for OAuth popups
18 )19 )
19 20
20-// HandlePopup handles popup window creation requests from WebViews21+// HandlePopup is DEPRECATED and removed.
21-func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {22+// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
22- log.Printf("[workspace] HandlePopup called for URL: %s", url)23+// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
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- // Popups are embedded in workspace paned containers - no separate window needed
74- webkitCfg.CreateWindow = false
75- // Create as related to avoid WindowFeatures crash; we'll decide final placement later
76- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
77- if err != nil {
78- log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
79- return nil
80- }
81-
82- // Register detection callback
83- newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
84- wm.RunOnUI(func() {
85- wm.handleDetectedWindowType(node, newView, url, t, feat)
86- })
87- })
88-
89- // Fallback: if detection never fires, treat as popup as before
90- go func() {
91- time.Sleep(1500 * time.Millisecond)
92- if newView != nil {
93- wm.RunOnUI(func() {
94- if wm.viewToNode[newView] == nil { // not yet placed
95- wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
96- }
97- })
98- }
99- }()
100-
101- return newView
102- }
103-
104- // Legacy path preserved
105- webkitCfg, err := wm.app.buildWebkitConfig()
106- if err != nil {
107- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
108- return nil
109- }
110- // Popups are embedded in workspace paned containers - no separate window needed
111- webkitCfg.CreateWindow = false
112- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
113- if err != nil {
114- log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
115- return nil
116- }
117-
118- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
119-
120- // Create a pane for the new WebView
121- newPane, err := wm.createPane(newView)
122- if err != nil {
123- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
124- return nil
125- }
126-
127- // Determine placement direction
128- direction := strings.ToLower(popCfg.Placement)
129- if direction == "" {
130- direction = "right"
131- }
132-
133- // Determine target node for splitting
134- target := node
135- if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
136- target = wm.GetActiveNode()
137- }
138-
139- // Add the popup pane to the workspace using manual pane insertion
140- if err := wm.insertPopupPane(target, newPane, direction); err != nil {
141- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
142- return nil
143- }
144-
145- // Get the new node and popup WebView ID for tracking
146- newNode := wm.viewToNode[newView]
147- popupWebViewID := newView.IDString()
148-
149- // Apply different behavior based on target type
150- if isBlankTarget {
151- log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
152- // For _blank targets, set up parent relationship but no auto-close
153- if newNode != nil {
154- newNode.parentPane = node
155- }
156-
157- // Register close handler to clean up activePopupChildren tracking
158- newView.RegisterCloseHandler(func() {
159- log.Printf("[workspace] _blank popup requesting close via window.close()")
160- // Remove this popup from parent's activePopupChildren
161- if node != nil && popupWebViewID != "" {
162- for i, childID := range node.activePopupChildren {
163- if childID == popupWebViewID {
164- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
165- log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
166- break
167- }
168- }
169- }
170- })
171- } else {
172- log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
173- // Mark as popup for auto-close handling (OAuth flows, etc.)
174- if newNode != nil {
175- newNode.isPopup = true
176- newNode.parentPane = node
177- log.Printf("[workspace] Marked pane as popup for auto-close handling")
178-
179- // Register close handler for popup auto-close on window.close()
180- newView.RegisterCloseHandler(func() {
181- log.Printf("[workspace] Popup requesting close via window.close()")
182-
183- // Remove this popup from parent's activePopupChildren
184- if node != nil && popupWebViewID != "" {
185- for i, childID := range node.activePopupChildren {
186- if childID == popupWebViewID {
187- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
188- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
189- break
190- }
191- }
192- }
193-
194- // Look up the node at close time
195- if node := wm.viewToNode[newView]; node != nil && node.isPopup {
196- log.Printf("[workspace] Closing popup pane")
197- // Brief delay to allow any final redirects to complete
198- time.AfterFunc(200*time.Millisecond, func() {
199- wm.scheduleIdleGuarded(func() bool {
200- if node == nil || !node.widgetValid {
201- return false
202- }
203- if err := wm.ClosePane(node); err != nil {
204- log.Printf("[workspace] Failed to close popup pane: %v", err)
205- }
206- return false
207- }, node)
208- })
209- } else {
210- log.Printf("[workspace] Could not find popup node for close handler")
211- }
212- })
213- } else {
214- log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
215- }
216- }
217-
218- // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
219- // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
220- if node != nil && popupWebViewID != "" {
221- if node.activePopupChildren == nil {
222- node.activePopupChildren = make([]string, 0)
223- }
224- node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
225- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
226-
227- // Register navigation policy handler for parent to block script-initiated navigations
228- // when it has active popup children (prevents OAuth from hijacking parent pane)
229- if node.pane != nil && node.pane.WebView() != nil {
230- node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
231- // Always allow user-initiated navigations
232- if isUserGesture {
233- return true
234- }
235-
236- // Block script-initiated navigations if this pane has active popup children
237- if len(node.activePopupChildren) > 0 {
238- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
239- return false
240- }
241-
242- // Allow if no active popups
243- return true
244- })
245- log.Printf("[workspace] Registered navigation policy handler for parent pane")
246- }
247- }
248-
249- // Ensure GUI components are available in the new pane
250- wm.ensureGUIInPane(newPane)
251-
252- // Inject GUI components into the popup pane
253- wm.ensureGUIInPane(newPane)
254-
255- // Load the URL if provided
256- if url != "" {
257- paneType := "popup"
258- if isBlankTarget {
259- paneType = "_blank target"
260- }
261- log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
262- if err := newView.LoadURL(url); err != nil {
263- log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
264- }
265- // Ensure the WebView is visible after loading URL
266- if err := newView.Show(); err != nil {
267- log.Printf("[workspace] failed to show popup WebView: %v", err)
268- }
269- }
270-
271- if isBlankTarget {
272- log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
273- } else {
274- log.Printf("[workspace] Created popup pane for URL: %s", url)
275- }
276- return newView
277-}
278 24
279 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups25 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups
280 // Note: OAuth detection is now handled by the main-world.js injection script26 // Note: OAuth detection is now handled by the main-world.js injection script
@@ -283,296 +29,6 @@ func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url str
283 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")29 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
284 }30 }
285 31
286-// applyWindowFeatures applies window features to a WebView based on intent
287-func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
288- if intent == nil {
289- return
290- }
291-
292- features := &webkit.WindowFeatures{}
293-
294- // Apply dimensions if specified
295- if intent.Width != nil {
296- features.Width = *intent.Width
297- }
298- if intent.Height != nil {
299- features.Height = *intent.Height
300- }
301-
302- // Apply visibility features based on window type
303- defaultToolbar := !isPopup
304- defaultLocation := !isPopup
305- defaultMenubar := !isPopup
306-
307- if intent.Toolbar != nil {
308- features.ToolbarVisible = *intent.Toolbar
309- } else {
310- features.ToolbarVisible = defaultToolbar
311- }
312-
313- if intent.Location != nil {
314- features.LocationbarVisible = *intent.Location
315- } else {
316- features.LocationbarVisible = defaultLocation
317- }
318-
319- if intent.Menubar != nil {
320- features.MenubarVisible = *intent.Menubar
321- } else {
322- features.MenubarVisible = defaultMenubar
323- }
324-
325- if intent.Resizable != nil {
326- features.Resizable = *intent.Resizable
327- } else {
328- features.Resizable = true // Usually resizable unless explicitly disabled
329- }
330-
331- view.SetWindowFeatures(features)
332- windowTypeStr := "tab"
333- if isPopup {
334- windowTypeStr = "popup"
335- }
336- log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
337- windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
338-}
339-
340-// handleIntentAsTab creates a tab pane based on window.open intent
341-func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
342- log.Printf("[workspace] Handling intent as tab: %s", url)
343-
344- webkitCfg, err := wm.app.buildWebkitConfig()
345- if err != nil {
346- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
347- return nil
348- }
349- // Tabs are embedded in workspace paned containers - no separate window needed
350- webkitCfg.CreateWindow = false
351-
352- newView, err := webkit.NewWebView(webkitCfg)
353- if err != nil {
354- log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
355- return nil
356- }
357-
358- newPane, err := wm.createPane(newView)
359- if err != nil {
360- log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
361- return nil
362- }
363-
364- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
365- if direction == "" {
366- direction = "right"
367- }
368-
369- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
370- log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
371- return nil
372- }
373-
374- node := wm.viewToNode[newView]
375- if node != nil {
376- node.windowType = webkit.WindowTypeTab
377- node.isRelated = false
378-
379- // Apply window features from JavaScript intent
380- wm.applyWindowFeatures(newView, intent, false)
381- }
382-
383- wm.ensureGUIInPane(newPane)
384-
385- if url != "" {
386- if err := newView.LoadURL(url); err != nil {
387- log.Printf("[workspace] failed to load tab URL: %v", err)
388- }
389- if err := newView.Show(); err != nil {
390- log.Printf("[workspace] failed to show tab WebView: %v", err)
391- }
392- }
393-
394- log.Printf("[workspace] Created tab pane for URL: %s", url)
395- return newView
396-}
397-
398-// handleIntentAsPopup creates a related popup pane based on window.open intent
399-func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
400- log.Printf("[workspace] Handling intent as popup: %s", url)
401-
402- webkitCfg, err := wm.app.buildWebkitConfig()
403- if err != nil {
404- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
405- return nil
406- }
407- // Popups are embedded in workspace paned containers - no separate window needed
408- webkitCfg.CreateWindow = false
409-
410- newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
411- if err != nil {
412- log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
413- return nil
414- }
415-
416- // Log the parent-popup WebView ID relationship for OAuth auto-close
417- parentWebViewID := sourceNode.pane.webView.IDString()
418- popupWebViewID := newView.IDString()
419- log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
420-
421- // Store popup WebView ID in parent's localStorage for OAuth callback lookup
422- storeScript := fmt.Sprintf(`
423- try {
424- const parentWebViewId = '%s';
425- const popupWebViewId = '%s';
426- const popupMapping = {
427- parentId: parentWebViewId,
428- popupId: popupWebViewId,
429- timestamp: Date.now(),
430- url: '%s'
431- };
432- localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
433- console.log('[workspace] Stored popup mapping:', popupMapping);
434- } catch(e) {
435- console.warn('[workspace] Failed to store popup mapping:', e);
436- }
437- `, parentWebViewID, popupWebViewID, url)
438-
439- // Inject into parent WebView so it can find its popup later
440- if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
441- log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
442- }
443-
444- newPane, err := wm.createPane(newView)
445- if err != nil {
446- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
447- return nil
448- }
449-
450- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
451- if direction == "" {
452- direction = "right"
453- }
454-
455- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
456- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
457- return nil
458- }
459-
460- node := wm.viewToNode[newView]
461- var requestID string
462- if node != nil {
463- node.windowType = webkit.WindowTypePopup
464- node.isRelated = true
465- node.parentPane = sourceNode
466- node.isPopup = true
467- node.autoClose = wm.shouldAutoClose(url)
468-
469- // Track this popup in the parent's activePopupChildren
470- if sourceNode != nil {
471- if sourceNode.activePopupChildren == nil {
472- sourceNode.activePopupChildren = make([]string, 0)
473- }
474- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
475- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
476-
477- // Register navigation policy handler for parent to block script-initiated navigations
478- // when it has active popup children (prevents OAuth from hijacking parent pane)
479- if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
480- sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
481- // Always allow user-initiated navigations
482- if isUserGesture {
483- return true
484- }
485-
486- // Block script-initiated navigations if this pane has active popup children
487- if len(sourceNode.activePopupChildren) > 0 {
488- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
489- return false
490- }
491-
492- // Allow if no active popups
493- return true
494- })
495- log.Printf("[workspace] Registered navigation policy handler for parent pane")
496- }
497- }
498-
499- // Store requestID for deduplication cleanup
500- if intent != nil {
501- requestID = intent.RequestID
502- node.requestID = requestID
503- }
504-
505- // Apply window features from JavaScript intent
506- wm.applyWindowFeatures(newView, intent, true)
507-
508- // Apply minimum size constraints to prevent compression
509- var width, height int
510- if intent != nil {
511- if intent.Width != nil {
512- width = *intent.Width
513- }
514- if intent.Height != nil {
515- height = *intent.Height
516- }
517- }
518- wm.applyPopupSizeConstraints(newView, width, height)
519- }
520-
521- // Register close handler for popup auto-close
522- newView.RegisterCloseHandler(func() {
523- log.Printf("[workspace] Popup requesting close via window.close()")
524-
525- // Clear the RequestID from deduplicator to allow new popups with same ID
526- if requestID != "" && wm.paneDeduplicator != nil {
527- wm.paneDeduplicator.ClearRequestID(requestID)
528- }
529-
530- // Remove this popup from parent's activePopupChildren
531- if sourceNode != nil && popupWebViewID != "" {
532- for i, childID := range sourceNode.activePopupChildren {
533- if childID == popupWebViewID {
534- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
535- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
536- break
537- }
538- }
539- }
540-
541- if n := wm.viewToNode[newView]; n != nil && n.isPopup {
542- time.AfterFunc(200*time.Millisecond, func() {
543- wm.scheduleIdleGuarded(func() bool {
544- if n == nil || !n.widgetValid {
545- return false
546- }
547- if err := wm.ClosePane(n); err != nil {
548- log.Printf("[workspace] Failed to close popup pane: %v", err)
549- }
550- return false
551- }, n)
552- })
553- }
554- })
555-
556- // URL-based auto-close for OAuth popups
557- if node != nil && node.isPopup && node.autoClose {
558- wm.registerOAuthAutoClose(newView, url)
559- }
560-
561- wm.ensureGUIInPane(newPane)
562-
563- if url != "" {
564- if err := newView.LoadURL(url); err != nil {
565- log.Printf("[workspace] failed to load popup URL: %v", err)
566- }
567- if err := newView.Show(); err != nil {
568- log.Printf("[workspace] failed to show popup WebView: %v", err)
569- }
570- }
571-
572- log.Printf("[workspace] Created popup pane for URL: %s", url)
573- return newView
574-}
575-
576 // insertIndependentPane inserts a new independent pane next to the source32 // insertIndependentPane inserts a new independent pane next to the source
577 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {33 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
578 newPane, err := wm.createPane(webView)34 newPane, err := wm.createPane(webView)
@@ -803,7 +259,7 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
803 // Tabs are embedded in workspace paned containers - no separate window needed259 // Tabs are embedded in workspace paned containers - no separate window needed
804 webkitCfg.CreateWindow = false260 webkitCfg.CreateWindow = false
805 261
806- // Create independent WebView like handleIntentAsTab does262+ // Create independent WebView matching our standard tab insertion path
807 independentView, err := webkit.NewWebView(webkitCfg)263 independentView, err := webkit.NewWebView(webkitCfg)
808 if err != nil {264 if err != nil {
809 log.Printf("[workspace] failed to create independent tab WebView: %v", err)265 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
@@ -824,3 +280,318 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
824 wm.configureRelatedPopup(sourceNode, webView, url, features)280 wm.configureRelatedPopup(sourceNode, webView, url, features)
825 }281 }
826 }282 }
283+
284+// setupPopupHandling connects WebView's create signal for popup lifecycle management
285+// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
286+func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
287+ if webView == nil || parentNode == nil {
288+ return
289+ }
290+
291+ webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
292+ // Get URL from navigation action
293+ req := navAction.Request()
294+ if req == nil {
295+ log.Printf("[workspace] create signal: no request in navigation action")
296+ return nil
297+ }
298+ url := req.URI()
299+
300+ log.Printf("[workspace] create signal received for URL: %s", url)
301+
302+ // Check if popups are enabled in config
303+ if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
304+ log.Printf("[workspace] Popup creation disabled in config - blocking popup")
305+ return nil
306+ }
307+
308+ // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
309+ // WebKit requires the related-view property to be set during construction for proper lifecycle
310+ // This ensures session/cookie sharing and proper WindowFeatures initialization
311+ // All initialization (wrapping, container, scripts) happens in ready-to-show
312+ log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
313+ barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
314+ if barePopupView == nil {
315+ log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
316+ return nil
317+ }
318+ log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
319+
320+ // Wrap the bare view immediately to get a proper ID
321+ // BUT don't initialize it yet - that happens in ready-to-show
322+ log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
323+ wrappedView := webkit.WrapBareWebView(barePopupView)
324+ if wrappedView == nil {
325+ log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
326+ return nil
327+ }
328+
329+ // Use the wrapper's ID for tracking
330+ popupID := wrappedView.ID()
331+ log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
332+
333+ // Store wrapped view (not initialized) for initialization in ready-to-show
334+ log.Printf("[workspace] Step 3: Storing in pendingPopups map")
335+ wm.pendingPopups[popupID] = &pendingPopup{
336+ wrappedView: wrappedView,
337+ parentView: webView,
338+ parentNode: parentNode,
339+ url: url,
340+ }
341+ log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
342+
343+ // CRITICAL: Do NOT connect signals before returning to WebKit
344+ // Connecting signals might trigger WebKit to access WindowFeatures before it's set
345+ // Instead, we'll connect them after WebKit has processed the return
346+ log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
347+
348+ // Use GLib idle_add to connect signals AFTER this callback returns
349+ // This ensures WebKit has finished configuring WindowFeatures before we touch the view
350+ glib.IdleAdd(func() bool {
351+ log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
352+
353+ // Connect ready-to-show signal NOW (after WebKit has configured the view)
354+ barePopupView.ConnectReadyToShow(func() {
355+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
356+ wm.handlePopupReadyToShow(popupID)
357+ })
358+ log.Printf("[workspace] Idle callback: ready-to-show connected")
359+
360+ // Connect close handler
361+ barePopupView.ConnectClose(func() {
362+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
363+ wm.handlePopupClose(popupID)
364+ })
365+ log.Printf("[workspace] Idle callback: close signal connected")
366+
367+ return false // Don't repeat
368+ })
369+
370+ log.Printf("[workspace] Step 4 complete: signal connections scheduled")
371+
372+ log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
373+
374+ // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
375+ return wrappedView
376+ })
377+}
378+
379+// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
380+func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
381+ info, ok := wm.pendingPopups[popupID]
382+ if !ok {
383+ log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
384+ return
385+ }
386+ delete(wm.pendingPopups, popupID)
387+
388+ log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
389+
390+ // NOW it's safe to initialize (WebKit has configured WindowProperties)
391+ // We already have the wrapped view from setupPopupHandling
392+ wrappedView := info.wrappedView
393+
394+ // Build webkit config for initialization
395+ webkitCfg, err := wm.app.buildWebkitConfig()
396+ if err != nil {
397+ log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
398+ return
399+ }
400+ webkitCfg.CreateWindow = false // Embedded in workspace
401+
402+ // Initialize the wrapped view with full configuration
403+ if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
404+ log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
405+ return
406+ }
407+
408+ log.Printf("[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+ log.Printf("[workspace] Failed to create popup pane: %v", err)
414+ return
415+ }
416+
417+ log.Printf("[workspace] Created popup pane, now inserting into workspace")
418+
419+ // NOW it's safe to insert into workspace based on configured behavior
420+ behavior := wm.app.config.Workspace.Popups.Behavior
421+ log.Printf("[workspace] Popup behavior: %s", behavior)
422+
423+ switch behavior {
424+ case config.PopupBehaviorSplit:
425+ // Split pane behavior (default)
426+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
427+ if direction == "" {
428+ direction = "right"
429+ }
430+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
431+ log.Printf("[workspace] Failed to insert split popup pane: %v", err)
432+ return
433+ }
434+
435+ case config.PopupBehaviorStacked:
436+ // Stacked pane behavior
437+ _, err := wm.stackedPaneManager.StackPane(info.parentNode)
438+ if err != nil {
439+ log.Printf("[workspace] Failed to stack existing pane: %v", err)
440+ return
441+ }
442+ // Insert the popup pane in the same container
443+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
444+ if direction == "" {
445+ direction = "right"
446+ }
447+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
448+ log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
449+ return
450+ }
451+
452+ case config.PopupBehaviorTabbed:
453+ // Tabbed behavior - TODO: implement proper tabbed interface
454+ log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
455+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
456+ if direction == "" {
457+ direction = "right"
458+ }
459+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
460+ log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
461+ return
462+ }
463+
464+ case config.PopupBehaviorWindowed:
465+ // Windowed behavior - opens in new workspace/window
466+ // TODO: implement new window creation with GTK
467+ log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
468+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
469+ if direction == "" {
470+ direction = "right"
471+ }
472+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
473+ log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
474+ return
475+ }
476+
477+ default:
478+ // Fallback to split if unknown behavior
479+ log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
480+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
481+ if direction == "" {
482+ direction = "right"
483+ }
484+ if err := wm.insertPopupPane(info.parentNode, popupPane, direction); err != nil {
485+ log.Printf("[workspace] Failed to insert popup pane: %v", err)
486+ return
487+ }
488+ }
489+
490+ // Configure the popup node
491+ node := wm.viewToNode[wrappedView]
492+ if node != nil {
493+ node.windowType = webkit.WindowTypePopup
494+ node.isRelated = true
495+ node.parentPane = info.parentNode
496+ node.isPopup = true
497+ node.autoClose = wm.shouldAutoClose(info.url)
498+ node.popupID = popupID // Store popupID for close signal lookup
499+
500+ // Track this popup in the parent's activePopupChildren
501+ if info.parentNode != nil {
502+ popupWebViewID := wrappedView.IDString()
503+ if info.parentNode.activePopupChildren == nil {
504+ info.parentNode.activePopupChildren = make([]string, 0)
505+ }
506+ info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
507+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
508+ popupWebViewID, len(info.parentNode.activePopupChildren))
509+
510+ // Register navigation policy handler for parent
511+ if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
512+ info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
513+ if isUserGesture {
514+ return true
515+ }
516+ if len(info.parentNode.activePopupChildren) > 0 {
517+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
518+ len(info.parentNode.activePopupChildren), url)
519+ return false
520+ }
521+ return true
522+ })
523+ }
524+ }
525+
526+ // URL-based auto-close for OAuth popups
527+ if node.autoClose {
528+ wm.registerOAuthAutoClose(wrappedView, info.url)
529+ }
530+ }
531+
532+ wm.ensureGUIInPane(popupPane)
533+
534+ // WebKit handles loading the URL automatically, just ensure visibility
535+ if err := wrappedView.Show(); err != nil {
536+ log.Printf("[workspace] failed to show popup WebView: %v", err)
537+ }
538+
539+ log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
540+}
541+
542+// handlePopupClose responds to WebKit's close signal
543+func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
544+ log.Printf("[workspace] close signal received for popup native=%d", popupID)
545+
546+ // Check if it's in pending (not yet shown)
547+ if info, ok := wm.pendingPopups[popupID]; ok {
548+ delete(wm.pendingPopups, popupID)
549+ log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
550+ // Just remove from pending, WebKit will handle cleanup
551+ _ = info // avoid unused variable
552+ return
553+ }
554+
555+ // Find pane by popupID (stored in node during ready-to-show)
556+ var targetNode *paneNode
557+ for _, node := range wm.viewToNode {
558+ if node != nil && node.popupID == popupID {
559+ targetNode = node
560+ break
561+ }
562+ }
563+
564+ if targetNode == nil {
565+ log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
566+ return
567+ }
568+
569+ // Remove from parent's activePopupChildren
570+ // Find the wrapped WebView for this node to get its IDString
571+ var wrappedViewID string
572+ for webView, node := range wm.viewToNode {
573+ if node == targetNode {
574+ wrappedViewID = webView.IDString()
575+ break
576+ }
577+ }
578+
579+ if targetNode.parentPane != nil && wrappedViewID != "" {
580+ for i, childID := range targetNode.parentPane.activePopupChildren {
581+ if childID == wrappedViewID {
582+ targetNode.parentPane.activePopupChildren = append(
583+ targetNode.parentPane.activePopupChildren[:i],
584+ targetNode.parentPane.activePopupChildren[i+1:]...)
585+ log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
586+ len(targetNode.parentPane.activePopupChildren))
587+ break
588+ }
589+ }
590+ }
591+
592+ // Clean close via existing ClosePane (WebKit already stopped)
593+ log.Printf("[workspace] Closing popup pane via ClosePane")
594+ if err := wm.ClosePane(targetNode); err != nil {
595+ log.Printf("[workspace] Error closing popup pane: %v", err)
596+ }
597+}
M internal/app/browser/workspace_types.go +28 -17
@@ -57,25 +57,26 @@ type paneNode struct {
5757 isPopup bool // Deprecated: use windowType instead
5858
5959 // Window type tracking
60- windowType webkit.WindowType // Tab or Popup
61- windowFeatures *webkit.WindowFeatures // Features if popup
62- isRelated bool // Shares context
63- parentPane *paneNode // Parent for related views
64- activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
65- autoClose bool // Auto-close on OAuth success
66- requestID string // Request ID for popup (used for localStorage cleanup)
67- hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
68- focusControllerToken uintptr // Focus controller token (stored as native pointer)
69- pendingHoverReattach bool // Needs hover reattachment after reparent
70- pendingFocusReattach bool // Needs focus reattachment after reparent
71- lastHoverTime int64 // Timestamp of last hover event (for debouncing)
60+ windowType webkit.WindowType // Tab or Popup
61+ windowFeatures *webkit.WindowFeatures // Features if popup
62+ isRelated bool // Shares context
63+ parentPane *paneNode // Parent for related views
64+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
65+ autoClose bool // Auto-close on OAuth success
66+ requestID string // Request ID for popup (used for localStorage cleanup)
67+ popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking)
68+ hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
69+ focusControllerToken uintptr // Focus controller token (stored as native pointer)
70+ pendingHoverReattach bool // Needs hover reattachment after reparent
71+ pendingFocusReattach bool // Needs focus reattachment after reparent
72+ lastHoverTime int64 // Timestamp of last hover event (for debouncing)
7273
7374 // Stacked panes support - terminal branch nodes
74- isStacked bool // Whether this node contains stacked panes
75- stackedPanes []*paneNode // List of stacked panes (if isStacked)
76- activeStackIndex int // Index of currently visible pane in stack
77- titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
78- stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
75+ isStacked bool // Whether this node contains stacked panes
76+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
77+ activeStackIndex int // Index of currently visible pane in stack
78+ titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
79+ stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
7980
8081 // Cleanup tracking
8182 widgetValid bool // Guard flagged before GTK destruction
@@ -92,5 +93,15 @@ const (
9293 stackContainerClass = "stacked-pane-container"
9394 )
9495
96+// pendingPopup tracks a popup WebView that has been created by WebKit but not
97+// yet fully initialized/inserted into the workspace. It allows the ready-to-show
98+// handler to finish setup once WebKit has configured window properties.
99+type pendingPopup struct {
100+ wrappedView *webkit.WebView
101+ parentView *webkit.WebView
102+ parentNode *paneNode
103+ url string
104+}
105+
95106 // Focus calculation epsilon for geometric comparisons
96107 const focusEpsilon = 1e-3
@@ -57,25 +57,26 @@ type paneNode struct {
57 isPopup bool // Deprecated: use windowType instead57 isPopup bool // Deprecated: use windowType instead
58 58
59 // Window type tracking59 // Window type tracking
60- windowType webkit.WindowType // Tab or Popup60+ windowType webkit.WindowType // Tab or Popup
61- windowFeatures *webkit.WindowFeatures // Features if popup61+ windowFeatures *webkit.WindowFeatures // Features if popup
62- isRelated bool // Shares context62+ isRelated bool // Shares context
63- parentPane *paneNode // Parent for related views63+ parentPane *paneNode // Parent for related views
64- activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)64+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
65- autoClose bool // Auto-close on OAuth success65+ autoClose bool // Auto-close on OAuth success
66- requestID string // Request ID for popup (used for localStorage cleanup)66+ requestID string // Request ID for popup (used for localStorage cleanup)
67- hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus67+ popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking)
68- focusControllerToken uintptr // Focus controller token (stored as native pointer)68+ hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
69- pendingHoverReattach bool // Needs hover reattachment after reparent69+ focusControllerToken uintptr // Focus controller token (stored as native pointer)
70- pendingFocusReattach bool // Needs focus reattachment after reparent70+ pendingHoverReattach bool // Needs hover reattachment after reparent
71- lastHoverTime int64 // Timestamp of last hover event (for debouncing)71+ pendingFocusReattach bool // Needs focus reattachment after reparent
72+ lastHoverTime int64 // Timestamp of last hover event (for debouncing)
72 73
73 // Stacked panes support - terminal branch nodes74 // Stacked panes support - terminal branch nodes
74- isStacked bool // Whether this node contains stacked panes75+ isStacked bool // Whether this node contains stacked panes
75- stackedPanes []*paneNode // List of stacked panes (if isStacked)76+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
76- activeStackIndex int // Index of currently visible pane in stack77+ activeStackIndex int // Index of currently visible pane in stack
77- titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)78+ titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
78- stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)79+ stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
79 80
80 // Cleanup tracking81 // Cleanup tracking
81 widgetValid bool // Guard flagged before GTK destruction82 widgetValid bool // Guard flagged before GTK destruction
@@ -92,5 +93,15 @@ const (
92 stackContainerClass = "stacked-pane-container"93 stackContainerClass = "stacked-pane-container"
93 )94 )
94 95
96+// pendingPopup tracks a popup WebView that has been created by WebKit but not
97+// yet fully initialized/inserted into the workspace. It allows the ready-to-show
98+// handler to finish setup once WebKit has configured window properties.
99+type pendingPopup struct {
100+ wrappedView *webkit.WebView
101+ parentView *webkit.WebView
102+ parentNode *paneNode
103+ url string
104+}
105+
95 // Focus calculation epsilon for geometric comparisons106 // Focus calculation epsilon for geometric comparisons
96 const focusEpsilon = 1e-3107 const focusEpsilon = 1e-3

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