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