1package browser
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "math"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bnema/dumber/internal/app/messaging"
13 "github.com/bnema/dumber/internal/config"
14 "github.com/bnema/dumber/pkg/webkit"
15)
16
17type paneNode struct {
18 pane *BrowserPane
19 parent *paneNode
20 left *paneNode
21 right *paneNode
22 container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
23 orientation webkit.Orientation
24 isLeaf bool
25 isPopup bool // Deprecated: use windowType instead
26 // Window type tracking
27 windowType webkit.WindowType // Tab or Popup
28 windowFeatures *webkit.WindowFeatures // Features if popup
29 isRelated bool // Shares context
30 parentPane *paneNode // Parent for related views
31 autoClose bool // Auto-close on OAuth success
32 hoverToken uintptr
33}
34
35// WorkspaceManager coordinates Zellij-style pane operations.
36type WorkspaceManager struct {
37 app *BrowserApp
38 window *webkit.Window
39 root *paneNode
40 active *paneNode
41 mainPane *paneNode
42 viewToNode map[*webkit.WebView]*paneNode
43 lastSplitMsg map[*webkit.WebView]time.Time
44 lastExitMsg map[*webkit.WebView]time.Time
45 paneModeActive bool
46 splitting bool
47 cssInitialized bool
48 createWebViewFn func() (*webkit.WebView, error)
49 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
50
51 // Coordination fields for preventing duplicate events
52 paneModeSource *webkit.WebView // Which webview initiated pane mode
53 lastPaneModeEntry time.Time // When pane mode was last entered
54 paneMutex sync.Mutex // Protects pane mode state
55
56 // Focus throttling fields to prevent infinite loops
57 lastFocusChange time.Time // When focus was last changed
58 focusThrottleMutex sync.Mutex // Protects focus throttling state
59
60 // NEW: Pane creation deduplicator
61 paneDeduplicator *messaging.PaneRequestDeduplicator
62}
63
64const (
65 activePaneClass = "workspace-pane-active"
66 basePaneClass = "workspace-pane"
67 multiPaneClass = "workspace-multi-pane"
68)
69
70// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
71
72// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
73func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
74 manager := &WorkspaceManager{
75 app: app,
76 window: rootPane.webView.Window(),
77 viewToNode: make(map[*webkit.WebView]*paneNode),
78 lastSplitMsg: make(map[*webkit.WebView]time.Time),
79 lastExitMsg: make(map[*webkit.WebView]time.Time),
80 paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
81 }
82 manager.createWebViewFn = func() (*webkit.WebView, error) {
83 if manager.app == nil {
84 return nil, errors.New("workspace manager missing app reference")
85 }
86 cfg, err := manager.app.buildWebkitConfig()
87 if err != nil {
88 return nil, err
89 }
90 // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization
91 // This prevents WindowFeatures optional crashes when WebKit accesses popup properties
92 cfg.CreateWindow = true
93 return webkit.NewWebView(cfg)
94 }
95 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
96 if manager.app == nil {
97 return nil, errors.New("workspace manager missing app reference")
98 }
99 return manager.app.createPaneForView(view)
100 }
101 manager.ensureStyles()
102
103 rootContainer := rootPane.webView.RootWidget()
104 root := &paneNode{
105 pane: rootPane,
106 container: rootContainer,
107 isLeaf: true,
108 }
109 manager.root = root
110 manager.active = root
111 manager.mainPane = root
112 manager.viewToNode[rootPane.webView] = root
113 manager.ensureHover(root)
114
115 if handler := rootPane.MessageHandler(); handler != nil {
116 handler.SetWorkspaceObserver(manager)
117 }
118
119 app.workspace = manager
120 manager.focusNode(root)
121 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
122 // Ensure initial CSS classes are applied
123 manager.ensurePaneBaseClasses()
124 return manager
125}
126
127// OnWorkspaceMessage implements messaging.WorkspaceObserver.
128func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
129 node := wm.viewToNode[source]
130 if node == nil {
131 log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
132 return
133 }
134
135 switch event := strings.ToLower(msg.Event); event {
136 case "pane-mode-entered":
137 // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
138 wm.paneMutex.Lock()
139 defer wm.paneMutex.Unlock()
140
141 // Check if pane mode was already entered recently (within 200ms debounce window)
142 if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
143 log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
144 time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
145 return
146 }
147
148 // Check if pane mode is already active from a different source
149 if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
150 log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
151 wm.paneModeSource, source)
152 return
153 }
154
155 log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
156 wm.paneModeActive = true
157 wm.paneModeSource = source
158 wm.lastPaneModeEntry = time.Now()
159 wm.focusNode(node)
160 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
161 // Debounce pane-mode-exited events to prevent duplicate focus calls
162 if event == "pane-mode-exited" {
163 if last, ok := wm.lastExitMsg[source]; ok {
164 if time.Since(last) < 100*time.Millisecond {
165 log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
166 return
167 }
168 }
169 wm.lastExitMsg[source] = time.Now()
170 }
171
172 wm.paneMutex.Lock()
173 wm.paneModeActive = false
174 wm.paneModeSource = nil
175 wm.paneMutex.Unlock()
176
177 wm.focusNode(node)
178 case "pane-close":
179 if !wm.paneModeActive {
180 log.Printf("[workspace] close requested outside pane mode; ignoring")
181 break
182 }
183
184 wm.paneMutex.Lock()
185 wm.paneModeActive = false
186 wm.paneModeSource = nil
187 wm.paneMutex.Unlock()
188
189 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
190 wm.closeCurrentPane()
191 case "pane-split":
192 wm.focusNode(node)
193 direction := strings.ToLower(msg.Direction)
194 if direction == "" {
195 direction = "right"
196 }
197 if last, ok := wm.lastSplitMsg[source]; ok {
198 if time.Since(last) < 200*time.Millisecond {
199 log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
200 return
201 }
202 }
203 if wm.splitting {
204 log.Printf("[workspace] split ignored: operation already in progress")
205 return
206 }
207 wm.splitting = true
208 wm.lastSplitMsg[source] = time.Now()
209 newNode, err := wm.splitNode(node, direction)
210 if err != nil {
211 log.Printf("[workspace] split failed: %v", err)
212 wm.splitting = false
213 return
214 }
215 wm.clonePaneState(node, newNode)
216 wm.splitting = false
217 case "create-pane":
218 log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
219
220 if msg.URL == "" {
221 log.Printf("[workspace] create-pane: empty URL, ignoring")
222 break
223 }
224
225 // NEW: Get WebView ID for deduplication
226 webViewID := "unknown"
227 if source != nil {
228 // Try to get a unique identifier for the WebView
229 webViewID = fmt.Sprintf("%p", source)
230 }
231
232 // NEW: Create intent for deduplication check
233 intent := &messaging.WindowIntent{
234 URL: msg.URL,
235 WindowType: msg.Action,
236 Timestamp: time.Now().UnixMilli(),
237 RequestID: msg.RequestID,
238 UserTriggered: true,
239 }
240
241 // NEW: Check for duplicates
242 if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
243 log.Printf("[workspace] create-pane BLOCKED: %s", reason)
244 break
245 }
246
247 // Use the existing methods to handle tab vs popup creation
248 switch strings.ToLower(msg.Action) {
249 case "tab":
250 newView := wm.handleIntentAsTab(node, msg.URL, intent)
251 if newView != nil {
252 log.Printf("[workspace] create-pane: tab created successfully")
253 } else {
254 log.Printf("[workspace] create-pane: failed to create tab")
255 }
256 case "popup":
257 newView := wm.handleIntentAsPopup(node, msg.URL, intent)
258 if newView != nil {
259 log.Printf("[workspace] create-pane: popup created successfully")
260 } else {
261 log.Printf("[workspace] create-pane: failed to create popup")
262 }
263 default:
264 log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
265 newView := wm.handleIntentAsTab(node, msg.URL, intent)
266 if newView != nil {
267 log.Printf("[workspace] create-pane: default tab created successfully")
268 } else {
269 log.Printf("[workspace] create-pane: failed to create default tab")
270 }
271 }
272 case "close-popup":
273 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
274
275 if msg.WebViewID == "" {
276 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
277 break
278 }
279
280 // Find the popup pane by webview ID
281 var targetNode *paneNode
282 for webView, node := range wm.viewToNode {
283 if webView != nil && webView.ID() == msg.WebViewID {
284 targetNode = node
285 break
286 }
287 }
288
289 if targetNode == nil {
290 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
291 break
292 }
293
294 if !targetNode.isPopup {
295 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
296 break
297 }
298
299 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
300
301 // Close the popup pane
302 if err := wm.closePane(targetNode); err != nil {
303 log.Printf("[workspace] Failed to close popup pane: %v", err)
304 } else {
305 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
306 }
307 default:
308 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
309 }
310}
311
312// GetActiveNode returns the currently active pane node
313func (wm *WorkspaceManager) GetActiveNode() *paneNode {
314 return wm.active
315}
316
317// GetNodeForWebView returns the pane node associated with a WebView
318func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
319 return wm.viewToNode[webView]
320}
321
322// RegisterNavigationHandler sets up navigation handling for a webview (simplified)
323func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
324 if webView == nil {
325 return
326 }
327
328 log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID())
329}
330
331// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
332func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
333 if node == nil || node.pane == nil || node.pane.webView == nil {
334 return
335 }
336
337 detail := map[string]any{
338 "active": active,
339 "webview": fmt.Sprintf("%p", node.pane.webView),
340 "webviewId": node.pane.webView.ID(),
341 "hasGUI": node.pane.HasGUI(),
342 "timestamp": time.Now().UnixMilli(),
343 }
344
345 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
346 log.Printf("[workspace] failed to dispatch focus event: %v", err)
347 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
348 log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
349 }
350}
351
352func (wm *WorkspaceManager) focusNode(node *paneNode) {
353 if node == nil || !node.isLeaf || node.pane == nil || node.pane.webView == nil {
354 return
355 }
356
357 // Don't focus a node that's not in our viewToNode map (likely destroyed)
358 if _, exists := wm.viewToNode[node.pane.webView]; !exists {
359 log.Printf("[workspace] focusNode: skipping destroyed/unknown webView=%p", node.pane.webView)
360 return
361 }
362
363 previous := wm.active
364 var previousContainer uintptr
365 var previousWebView *webkit.WebView
366 if previous != nil && previous != node && previous.pane != nil && previous.pane.webView != nil {
367 previousContainer = previous.container
368 previousWebView = previous.pane.webView
369 wm.DispatchPaneFocusEvent(previous, false)
370 }
371
372 // Set previously active WebView as inactive
373 if previousWebView != nil && previousWebView != node.pane.webView {
374 previousWebView.SetActive(false)
375 }
376
377 wm.active = node
378 wm.app.activePane = node.pane
379 wm.app.webView = node.pane.webView
380 wm.app.zoomController = node.pane.zoomController
381 wm.app.navigationController = node.pane.navigationController
382 wm.app.clipboardController = node.pane.clipboardController
383 wm.app.messageHandler = node.pane.messageHandler
384 wm.app.shortcutHandler = node.pane.shortcutHandler
385
386 // Set this WebView as active
387 if node.pane != nil && node.pane.webView != nil {
388 node.pane.webView.SetActive(true)
389 }
390
391 // Shortcuts are registered once during webView creation, no need to re-register on focus
392
393 if handler := node.pane.messageHandler; handler != nil {
394 handler.SetWorkspaceObserver(wm)
395 }
396
397 if wm.app.browserService != nil {
398 wm.app.browserService.AttachWebView(node.pane.webView)
399 }
400
401 // Ensure all panes have proper base classes
402 wm.ensurePaneBaseClasses()
403
404 container := node.container
405 viewWidget := node.pane.webView.Widget()
406 if container != 0 && container != previousContainer {
407 // Remove active class from previous container to ensure only one pane is visually active
408 if previousContainer != 0 {
409 webkit.WidgetRemoveCSSClass(previousContainer, activePaneClass)
410 }
411
412 // Add active class to current container (border visibility controlled by CSS multi-pane rules)
413 webkit.WidgetAddCSSClass(container, activePaneClass)
414 if !webkit.WidgetIsValid(container) {
415 log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
416 return
417 }
418 webkit.IdleAdd(func() bool {
419 if !webkit.WidgetIsValid(container) {
420 log.Printf("[workspace] focus aborted during idle: container invalid widget=%#x", container)
421 return false
422 }
423 parent := webkit.WidgetGetParent(container)
424 if parent != 0 {
425 webkit.WidgetRealizeInContainer(container)
426 if viewWidget != 0 {
427 webkit.WidgetGrabFocus(viewWidget)
428 }
429 // Consolidated focus operation log
430 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
431 log.Printf("[workspace] focus operations completed: container=%#x parent=%#x viewWidget=%#x", container, parent, viewWidget)
432 }
433 } else {
434 log.Printf("[workspace] focus deferred: widget not parented")
435 }
436 return false // Remove idle callback
437 })
438 }
439
440 if node.pane != nil && node.pane.webView != nil {
441 // Update pane focus time
442 node.pane.UpdateLastFocus()
443
444 // Always dispatch focus event to ensure JavaScript side knows the active pane
445 // This fixes the issue where navigation within the same pane doesn't update JS state
446 wm.DispatchPaneFocusEvent(node, true)
447
448 // Lazy-load GUI components if first focus
449 if !node.pane.HasGUI() {
450 wm.ensureGUIInPane(node.pane)
451 }
452 }
453}
454
455// generateActivePaneCSS generates the CSS for workspace panes based on config
456func (wm *WorkspaceManager) generateActivePaneCSS() string {
457 cfg := config.Get()
458 styling := cfg.Workspace.Styling
459
460 // Get appropriate border colors based on GTK theme preference
461 var inactiveBorderColor, windowBackgroundColor string
462 isDark := webkit.PrefersDarkTheme()
463 if isDark {
464 inactiveBorderColor = "#333333" // Dark border for dark theme
465 windowBackgroundColor = "#2b2b2b" // Dark window background
466 } else {
467 inactiveBorderColor = "#dddddd" // Light border for light theme
468 windowBackgroundColor = "#ffffff" // Light window background
469 }
470
471 // Log the border color values for debugging
472 log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
473
474 css := fmt.Sprintf(`window {
475 background-color: %s;
476 }
477
478 .workspace-pane {
479 background-color: %s;
480 border: %dpx solid %s;
481 transition: border-color %dms ease-in-out;
482 border-radius: %dpx;
483 }
484
485 .workspace-pane.workspace-multi-pane {
486 border: %dpx solid %s;
487 border-radius: %dpx;
488 }
489
490 .workspace-pane.workspace-multi-pane.workspace-pane-active {
491 border-color: %s;
492 }`,
493 windowBackgroundColor,
494 windowBackgroundColor,
495 styling.BorderWidth,
496 inactiveBorderColor,
497 styling.TransitionDuration,
498 styling.BorderRadius,
499 styling.BorderWidth,
500 inactiveBorderColor,
501 styling.BorderRadius,
502 styling.BorderColor,
503 )
504
505 // Log the actual CSS being generated
506 log.Printf("[workspace] Generated CSS: %s", css)
507
508 return css
509}
510
511func (wm *WorkspaceManager) ensureStyles() {
512 if wm == nil || wm.cssInitialized {
513 return
514 }
515 activePaneCSS := wm.generateActivePaneCSS()
516 webkit.AddCSSProvider(activePaneCSS)
517 wm.cssInitialized = true
518}
519
520// hasMultiplePanes returns true if there are multiple panes in the workspace
521func (wm *WorkspaceManager) hasMultiplePanes() bool {
522 return wm != nil && wm.app != nil && len(wm.app.panes) > 1
523}
524
525// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
526func (wm *WorkspaceManager) ensurePaneBaseClasses() {
527 if wm == nil {
528 return
529 }
530
531 leaves := wm.collectLeaves()
532 for _, leaf := range leaves {
533 if leaf != nil && leaf.container != 0 {
534 webkit.WidgetAddCSSClass(leaf.container, basePaneClass)
535 if wm.hasMultiplePanes() {
536 webkit.WidgetAddCSSClass(leaf.container, multiPaneClass)
537 } else {
538 webkit.WidgetRemoveCSSClass(leaf.container, multiPaneClass)
539 }
540 }
541 }
542}
543
544func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
545 if wm == nil || view == nil {
546 return
547 }
548
549 // Throttle focus changes to prevent infinite loops
550 wm.focusThrottleMutex.Lock()
551 const focusThrottleInterval = 100 * time.Millisecond
552 if time.Since(wm.lastFocusChange) < focusThrottleInterval {
553 wm.focusThrottleMutex.Unlock()
554 return
555 }
556 wm.lastFocusChange = time.Now()
557 wm.focusThrottleMutex.Unlock()
558
559 if node, ok := wm.viewToNode[view]; ok {
560 if wm.active != node {
561 wm.focusNode(node)
562 }
563 }
564}
565
566func (wm *WorkspaceManager) ensureHover(node *paneNode) {
567 if wm == nil || node == nil || !node.isLeaf {
568 return
569 }
570 if node.container == 0 || node.hoverToken != 0 {
571 return
572 }
573
574 token := webkit.WidgetAddHoverHandler(node.container, func() {
575 if wm == nil {
576 return
577 }
578 wm.focusNode(node)
579 })
580 node.hoverToken = token
581 if token == 0 {
582 log.Printf("[workspace] failed to attach hover handler: widget=%#x", node.container)
583 }
584}
585
586func (wm *WorkspaceManager) detachHover(node *paneNode) {
587 if wm == nil || node == nil || node.hoverToken == 0 {
588 return
589 }
590 webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
591 node.hoverToken = 0
592}
593
594// FocusNeighbor moves focus to the nearest pane in the requested direction using the
595// actual widget geometry to determine adjacency.
596func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
597 if wm == nil {
598 return false
599 }
600 switch strings.ToLower(direction) {
601 case "left", "right", "up", "down":
602 return wm.focusAdjacent(strings.ToLower(direction))
603 default:
604 return false
605 }
606}
607
608const focusEpsilon = 1e-3
609
610func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
611 if wm.active == nil || !wm.active.isLeaf || wm.active.container == 0 {
612 return false
613 }
614
615 if neighbor := wm.structuralNeighbor(wm.active, direction); neighbor != nil {
616 wm.focusNode(neighbor)
617 return true
618 }
619
620 currentBounds, ok := webkit.WidgetGetBounds(wm.active.container)
621 if !ok {
622 log.Printf("[workspace] unable to compute bounds for active pane")
623 return false
624 }
625
626 cx := currentBounds.X + currentBounds.Width/2.0
627 cy := currentBounds.Y + currentBounds.Height/2.0
628
629 leaves := wm.collectLeaves()
630 bestScore := math.MaxFloat64
631 var best *paneNode
632 var debugCandidates []string
633
634 for _, candidate := range leaves {
635 if candidate == nil || candidate == wm.active || candidate.container == 0 {
636 continue
637 }
638 bounds, ok := webkit.WidgetGetBounds(candidate.container)
639 if !ok {
640 continue
641 }
642 tx := bounds.X + bounds.Width/2.0
643 ty := bounds.Y + bounds.Height/2.0
644
645 dx := tx - cx
646 dy := ty - cy
647
648 var score float64
649 switch direction {
650 case "left":
651 if dx >= -focusEpsilon {
652 continue
653 }
654 score = math.Abs(dx)*1000 + math.Abs(dy)
655 case "right":
656 if dx <= focusEpsilon {
657 continue
658 }
659 score = math.Abs(dx)*1000 + math.Abs(dy)
660 case "up":
661 if dy >= -focusEpsilon {
662 continue
663 }
664 score = math.Abs(dy)*1000 + math.Abs(dx)
665 case "down":
666 if dy <= focusEpsilon {
667 continue
668 }
669 score = math.Abs(dy)*1000 + math.Abs(dx)
670 }
671
672 if direction == "up" || direction == "down" {
673 debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
674 }
675
676 if score < bestScore {
677 bestScore = score
678 best = candidate
679 }
680 }
681
682 if best != nil {
683 wm.focusNode(best)
684 return true
685 }
686
687 if len(debugCandidates) > 0 {
688 log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.active.container, strings.Join(debugCandidates, "; "))
689 }
690 return false
691}
692
693func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
694 if node == nil || node.container == 0 {
695 return nil
696 }
697
698 refBounds, ok := webkit.WidgetGetBounds(node.container)
699 if !ok {
700 return nil
701 }
702 cx := refBounds.X + refBounds.Width/2.0
703 cy := refBounds.Y + refBounds.Height/2.0
704 axisVertical := direction == "up" || direction == "down"
705
706 for parent := node.parent; parent != nil; parent = parent.parent {
707 switch direction {
708 case "up":
709 if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
710 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
711 return leaf
712 }
713 }
714 case "down":
715 if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
716 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
717 return leaf
718 }
719 }
720 case "left":
721 if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
722 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
723 return leaf
724 }
725 }
726 case "right":
727 if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
728 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
729 return leaf
730 }
731 }
732 }
733 node = parent
734 }
735 return nil
736}
737
738func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
739 leaves := wm.collectLeavesFrom(node)
740 bestScore := math.MaxFloat64
741 var best *paneNode
742 for _, leaf := range leaves {
743 if leaf == nil || leaf.container == 0 {
744 continue
745 }
746 bounds, ok := webkit.WidgetGetBounds(leaf.container)
747 if !ok {
748 continue
749 }
750 tx := bounds.X + bounds.Width/2.0
751 ty := bounds.Y + bounds.Height/2.0
752 dx := tx - cx
753 dy := ty - cy
754 var score float64
755 switch direction {
756 case "left":
757 if dx >= -focusEpsilon {
758 continue
759 }
760 score = math.Abs(dx)*1000 + math.Abs(dy)
761 case "right":
762 if dx <= focusEpsilon {
763 continue
764 }
765 score = math.Abs(dx)*1000 + math.Abs(dy)
766 case "up":
767 if dy >= -focusEpsilon {
768 continue
769 }
770 score = math.Abs(dy)*1000 + math.Abs(dx)
771 case "down":
772 if dy <= focusEpsilon {
773 continue
774 }
775 score = math.Abs(dy)*1000 + math.Abs(dx)
776 default:
777 continue
778 }
779 if score < bestScore {
780 bestScore = score
781 best = leaf
782 }
783 }
784 if best == nil {
785 return wm.boundaryFallback(node, direction)
786 }
787 return best
788}
789
790func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
791 return wm.boundaryFallbackWithDepth(node, direction, 0)
792}
793
794func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
795 // Prevent infinite recursion - max tree depth should be reasonable
796 const maxDepth = 50
797 if depth > maxDepth {
798 log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
799 return nil
800 }
801
802 if node == nil {
803 return nil
804 }
805 if node.isLeaf {
806 return node
807 }
808 switch direction {
809 case "up":
810 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
811 return leaf
812 }
813 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
814 case "down":
815 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
816 return leaf
817 }
818 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
819 case "left":
820 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
821 return leaf
822 }
823 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
824 case "right":
825 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
826 return leaf
827 }
828 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
829 default:
830 return nil
831 }
832}
833
834func (wm *WorkspaceManager) collectLeaves() []*paneNode {
835 return wm.collectLeavesFrom(wm.root)
836}
837
838func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
839 var leaves []*paneNode
840 visited := make(map[*paneNode]bool)
841
842 var walk func(*paneNode, int)
843 walk = func(n *paneNode, depth int) {
844 // Prevent infinite recursion and cycles
845 const maxDepth = 50
846 if n == nil || depth > maxDepth {
847 return
848 }
849 if visited[n] {
850 log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
851 return
852 }
853 visited[n] = true
854
855 if n.isLeaf {
856 leaves = append(leaves, n)
857 return
858 }
859 walk(n.left, depth+1)
860 walk(n.right, depth+1)
861 }
862 walk(node, 0)
863 return leaves
864}
865
866func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
867 if wm == nil || wm.createWebViewFn == nil {
868 return nil, errors.New("workspace manager missing webview factory")
869 }
870 return wm.createWebViewFn()
871}
872
873func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
874 if wm == nil || wm.createPaneFn == nil {
875 return nil, errors.New("workspace manager missing pane factory")
876 }
877 return wm.createPaneFn(view)
878}
879
880func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
881 if target == nil || !target.isLeaf || target.pane == nil {
882 return nil, errors.New("split target must be a leaf pane")
883 }
884
885 newView, err := wm.createWebView()
886 if err != nil {
887 return nil, err
888 }
889
890 newPane, err := wm.createPane(newView)
891 if err != nil {
892 return nil, err
893 }
894
895 if handler := newPane.MessageHandler(); handler != nil {
896 handler.SetWorkspaceObserver(wm)
897 }
898
899 newContainer := newPane.webView.RootWidget()
900 if newContainer == 0 {
901 return nil, errors.New("new pane missing container")
902 }
903 webkit.WidgetSetHExpand(newContainer, true)
904 webkit.WidgetSetVExpand(newContainer, true)
905 webkit.WidgetRealizeInContainer(newContainer)
906
907 existingContainer := target.container
908 if existingContainer == 0 {
909 return nil, errors.New("existing pane missing container")
910 }
911 webkit.WidgetSetHExpand(existingContainer, true)
912 webkit.WidgetSetVExpand(existingContainer, true)
913 webkit.WidgetRealizeInContainer(existingContainer)
914
915 orientation, existingFirst := mapDirection(direction)
916 log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
917
918 paned := webkit.NewPaned(orientation)
919 if paned == 0 {
920 return nil, errors.New("failed to create GtkPaned")
921 }
922 webkit.WidgetSetHExpand(paned, true)
923 webkit.WidgetSetVExpand(paned, true)
924 webkit.PanedSetResizeStart(paned, true)
925 webkit.PanedSetResizeEnd(paned, true)
926
927 newLeaf := &paneNode{
928 pane: newPane,
929 container: newContainer,
930 isLeaf: true,
931 }
932 split := &paneNode{
933 parent: target.parent,
934 container: paned,
935 orientation: orientation,
936 isLeaf: false,
937 }
938
939 parent := split.parent
940
941 // Detach existing container from its current GTK parent before inserting into new paned.
942 if parent == nil {
943 // Target is the root - remove it from the window and unparent it
944 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
945 if wm.window != nil {
946 wm.window.SetChild(0)
947 }
948 } else if parent.container != 0 {
949 // Target has a parent paned - unparent it from there
950 log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
951 if parent.left == target {
952 webkit.PanedSetStartChild(parent.container, 0)
953 } else if parent.right == target {
954 webkit.PanedSetEndChild(parent.container, 0)
955 }
956 }
957
958 // Properly unparent the widget from GTK's perspective
959 webkit.WidgetUnparent(existingContainer)
960 if parent != nil && parent.container != 0 {
961 webkit.WidgetQueueAllocate(parent.container)
962 }
963
964 // Set up the tree structure first
965 if existingFirst {
966 split.left = target
967 split.right = newLeaf
968 } else {
969 split.left = newLeaf
970 split.right = target
971 }
972
973 target.parent = split
974 newLeaf.parent = split
975
976 // Update tree root/parent references
977 if parent == nil {
978 wm.root = split
979 } else {
980 if parent.left == target {
981 parent.left = split
982 } else if parent.right == target {
983 parent.right = split
984 }
985 }
986
987 // Use idle callback to ensure widget unparenting is complete before re-parenting
988 webkit.IdleAdd(func() bool {
989 // Add both containers to the new paned
990 if existingFirst {
991 webkit.PanedSetStartChild(paned, existingContainer)
992 webkit.PanedSetEndChild(paned, newContainer)
993 log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
994 } else {
995 webkit.PanedSetStartChild(paned, newContainer)
996 webkit.PanedSetEndChild(paned, existingContainer)
997 log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
998 }
999
1000 // Attach the new paned to its parent
1001 if parent == nil {
1002 if wm.window != nil {
1003 wm.window.SetChild(paned)
1004 webkit.WidgetQueueAllocate(paned)
1005 log.Printf("[workspace] paned set as window child: paned=%#x", paned)
1006 }
1007 } else {
1008 if parent.left == split {
1009 webkit.PanedSetStartChild(parent.container, paned)
1010 } else if parent.right == split {
1011 webkit.PanedSetEndChild(parent.container, paned)
1012 }
1013 webkit.WidgetQueueAllocate(parent.container)
1014 webkit.WidgetQueueAllocate(paned)
1015 log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
1016 }
1017
1018 webkit.WidgetShow(paned)
1019 return false // Run once
1020 })
1021
1022 wm.viewToNode[newPane.webView] = newLeaf
1023 wm.ensureHover(newLeaf)
1024 wm.ensureHover(target)
1025 wm.app.panes = append(wm.app.panes, newPane)
1026 if newPane.zoomController != nil {
1027 newPane.zoomController.ApplyInitialZoom()
1028 }
1029
1030 // Update CSS classes for all panes now that we have multiple panes
1031 wm.ensurePaneBaseClasses()
1032
1033 webkit.IdleAdd(func() bool {
1034 if newContainer != 0 {
1035 webkit.WidgetShow(newContainer)
1036 }
1037 wm.focusNode(newLeaf)
1038 return false
1039 })
1040
1041 return newLeaf, nil
1042}
1043
1044func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
1045 if wm == nil || target == nil {
1046 return
1047 }
1048 if target.pane == nil || target.pane.webView == nil {
1049 return
1050 }
1051
1052 const blankURL = "about:blank"
1053
1054 if err := target.pane.webView.LoadURL(blankURL); err != nil {
1055 log.Printf("[workspace] failed to prepare blank pane: %v", err)
1056 }
1057
1058 // Wait for about:blank to load before opening omnibox
1059 target.pane.webView.RegisterURIChangedHandler(func(uri string) {
1060 if uri == blankURL {
1061 // Defer omnibox opening to allow page to fully initialize
1062 webkit.IdleAdd(func() bool {
1063 if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
1064 log.Printf("[workspace] failed to open omnibox: %v", injErr)
1065 }
1066 return false // Remove idle callback
1067 })
1068 }
1069 })
1070}
1071
1072func (wm *WorkspaceManager) closeCurrentPane() {
1073 if wm == nil || wm.active == nil {
1074 return
1075 }
1076 if err := wm.closePane(wm.active); err != nil {
1077 log.Printf("[workspace] close current pane failed: %v", err)
1078 }
1079}
1080
1081func (wm *WorkspaceManager) closePane(node *paneNode) error {
1082 if wm == nil || node == nil || !node.isLeaf {
1083 return errors.New("close target must be a leaf pane")
1084 }
1085 if node.pane == nil || node.pane.webView == nil {
1086 return errors.New("close target missing webview")
1087 }
1088
1089 if node.parent != nil && node.parent.container != 0 {
1090 if node.parent.left == node {
1091 webkit.PanedSetStartChild(node.parent.container, 0)
1092 } else if node.parent.right == node {
1093 webkit.PanedSetEndChild(node.parent.container, 0)
1094 }
1095 }
1096
1097 if node.container != 0 {
1098 webkit.WidgetUnparent(node.container)
1099 }
1100
1101 remaining := len(wm.app.panes)
1102 willBeLastPane := remaining <= 1
1103 paneCleaned := false
1104 ensureCleanup := func() {
1105 if paneCleaned {
1106 return
1107 }
1108 node.pane.Cleanup()
1109 paneCleaned = true
1110 }
1111
1112 if remaining == 2 {
1113 var sibling *paneNode
1114 if node.parent != nil {
1115 if node.parent.left == node {
1116 sibling = node.parent.right
1117 } else if node.parent.right == node {
1118 sibling = node.parent.left
1119 }
1120 }
1121 if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
1122 if sibling.isPopup && !node.isPopup {
1123 parentNode := node.parent
1124 parentContainer := uintptr(0)
1125 if parentNode != nil {
1126 parentContainer = parentNode.container
1127 }
1128
1129 // Detach remaining GTK widgets before destroying the WebViews so GTK
1130 // does not attempt to dispose still-parented children.
1131 if parentContainer != 0 {
1132 if parentNode.left == sibling {
1133 webkit.PanedSetStartChild(parentContainer, 0)
1134 } else if parentNode.right == sibling {
1135 webkit.PanedSetEndChild(parentContainer, 0)
1136 }
1137 }
1138 if sibling.container != 0 {
1139 webkit.WidgetUnparent(sibling.container)
1140 }
1141 if parentContainer != 0 && wm.window != nil {
1142 wm.window.SetChild(0)
1143 }
1144
1145 log.Printf("[workspace] closing primary pane with related popup present; exiting")
1146 ensureCleanup()
1147 sibling.pane.Cleanup()
1148
1149 wm.detachHover(node)
1150 wm.detachHover(sibling)
1151
1152 delete(wm.viewToNode, node.pane.webView)
1153 delete(wm.viewToNode, sibling.pane.webView)
1154 delete(wm.lastSplitMsg, node.pane.webView)
1155 delete(wm.lastSplitMsg, sibling.pane.webView)
1156 delete(wm.lastExitMsg, node.pane.webView)
1157 delete(wm.lastExitMsg, sibling.pane.webView)
1158
1159 for i := 0; i < len(wm.app.panes); i++ {
1160 if wm.app.panes[i] == node.pane || wm.app.panes[i] == sibling.pane {
1161 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1162 i--
1163 }
1164 }
1165
1166 if node.pane.webView != nil {
1167 if err := node.pane.webView.Destroy(); err != nil {
1168 log.Printf("[workspace] failed to destroy primary webview: %v", err)
1169 }
1170 }
1171 if sibling.pane.webView != nil {
1172 if err := sibling.pane.webView.Destroy(); err != nil {
1173 log.Printf("[workspace] failed to destroy popup webview: %v", err)
1174 }
1175 }
1176
1177 wm.root = nil
1178 wm.active = nil
1179 wm.mainPane = nil
1180
1181 webkit.QuitMainLoop()
1182 return nil
1183 }
1184 }
1185 }
1186 if willBeLastPane && wm.root == node {
1187 log.Printf("[workspace] closing final pane; exiting browser")
1188 ensureCleanup()
1189 wm.detachHover(node)
1190 if err := node.pane.webView.Destroy(); err != nil {
1191 log.Printf("[workspace] failed to destroy webview: %v", err)
1192 }
1193 webkit.QuitMainLoop()
1194 return nil
1195 }
1196
1197 parent := node.parent
1198 if parent == nil {
1199 // This is the root pane (no parent in tree structure)
1200 if node != wm.root {
1201 return errors.New("inconsistent state: node has no parent but is not root")
1202 }
1203
1204 // Check if we can find a replacement root
1205 replacement := wm.findReplacementRoot(node)
1206 if replacement == nil {
1207 // No other panes exist, this is the final pane
1208 log.Printf("[workspace] closing final pane; exiting browser")
1209 ensureCleanup()
1210 wm.detachHover(node)
1211 if err := node.pane.webView.Destroy(); err != nil {
1212 log.Printf("[workspace] failed to destroy webview: %v", err)
1213 }
1214 webkit.QuitMainLoop()
1215 return nil
1216 }
1217
1218 // We have a replacement - delegate root status and close this pane
1219 log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
1220
1221 // Clean up references first
1222 delete(wm.viewToNode, node.pane.webView)
1223 delete(wm.lastSplitMsg, node.pane.webView)
1224 delete(wm.lastExitMsg, node.pane.webView)
1225
1226 for i, pane := range wm.app.panes {
1227 if pane == node.pane {
1228 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1229 break
1230 }
1231 }
1232
1233 if wm.mainPane == node {
1234 wm.mainPane = nil
1235 }
1236
1237 // Clear current active if it's the node being closed
1238 if wm.active == node {
1239 wm.active = nil
1240 }
1241
1242 // Set replacement as new root
1243 wm.root = replacement
1244 replacement.parent = nil
1245
1246 // Replace window child directly (GTK handles reparenting automatically)
1247 if wm.window != nil {
1248 wm.window.SetChild(replacement.container)
1249 if replacement.container != 0 {
1250 webkit.WidgetQueueAllocate(replacement.container)
1251 webkit.WidgetShow(replacement.container)
1252 }
1253 }
1254
1255 // Focus a suitable pane
1256 focusTarget := wm.leftmostLeaf(replacement)
1257 if focusTarget != nil {
1258 wm.focusNode(focusTarget)
1259 }
1260
1261 // Destroy the webview and detach hover AFTER rearranging hierarchy
1262 // Only destroy the webview if this is the final pane, otherwise just clean up
1263 ensureCleanup()
1264 wm.detachHover(node)
1265 switch {
1266 case node.isPopup:
1267 if err := node.pane.webView.Destroy(); err != nil {
1268 log.Printf("[workspace] failed to destroy popup webview: %v", err)
1269 }
1270 case willBeLastPane:
1271 // This is the last pane, safe to destroy completely
1272 if err := node.pane.webView.Destroy(); err != nil {
1273 log.Printf("[workspace] failed to destroy webview: %v", err)
1274 }
1275 default:
1276 // Multiple panes remain, don't destroy the window - just clean up the webview
1277 log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1278 // TODO: Add a method to destroy just the webview without the window
1279 }
1280
1281 wm.updateMainPane()
1282 // Update CSS classes after pane count changes
1283 wm.ensurePaneBaseClasses()
1284 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
1285 return nil
1286 }
1287
1288 var sibling *paneNode
1289 if parent.left == node {
1290 sibling = parent.right
1291 } else {
1292 sibling = parent.left
1293 }
1294 if sibling == nil {
1295 return errors.New("pane close failed: missing sibling")
1296 }
1297
1298 log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
1299
1300 // Find focus target before modifying the tree structure
1301 focusTarget := wm.leftmostLeaf(sibling)
1302 if focusTarget == nil {
1303 focusTarget = wm.leftmostLeaf(wm.root)
1304 }
1305
1306 // Clean up references first
1307 delete(wm.viewToNode, node.pane.webView)
1308 delete(wm.lastSplitMsg, node.pane.webView)
1309 delete(wm.lastExitMsg, node.pane.webView)
1310
1311 for i, pane := range wm.app.panes {
1312 if pane == node.pane {
1313 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1314 break
1315 }
1316 }
1317
1318 if wm.mainPane == node {
1319 wm.mainPane = nil
1320 }
1321
1322 // Clear current active if it's the node being closed
1323 if wm.active == node {
1324 wm.active = nil
1325 }
1326
1327 grand := parent.parent
1328 if grand == nil {
1329 // Parent is the root node. Promote sibling to become the new root.
1330 // The sibling can be either a leaf (when only 2 panes total) or a branch (when more panes exist)
1331 log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1332 wm.root = sibling
1333 sibling.parent = nil
1334
1335 // Unparent the sibling from the paned first, then set it as window child
1336 if wm.window != nil {
1337 // GTK requires explicit unparenting before reparenting
1338 if parent.left == sibling {
1339 webkit.PanedSetStartChild(parent.container, 0)
1340 } else {
1341 webkit.PanedSetEndChild(parent.container, 0)
1342 }
1343 // Now we can safely set it as window child
1344 wm.window.SetChild(sibling.container)
1345 if sibling.container != 0 {
1346 webkit.WidgetQueueAllocate(sibling.container)
1347 }
1348 }
1349 } else {
1350 // Parent has a grandparent, so promote sibling to take parent's place
1351 log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1352
1353 // First unparent the sibling from its current parent to avoid GTK-CRITICAL errors
1354 if parent.container != 0 && !parent.isLeaf {
1355 if parent.left == sibling {
1356 webkit.PanedSetStartChild(parent.container, 0)
1357 } else if parent.right == sibling {
1358 webkit.PanedSetEndChild(parent.container, 0)
1359 }
1360 }
1361
1362 // Now safely reparent the sibling to the grandparent
1363 sibling.parent = grand
1364 if grand.container != 0 && !grand.isLeaf {
1365 if grand.left == parent {
1366 grand.left = sibling
1367 webkit.PanedSetStartChild(grand.container, sibling.container)
1368 } else if grand.right == parent {
1369 grand.right = sibling
1370 webkit.PanedSetEndChild(grand.container, sibling.container)
1371 }
1372 webkit.WidgetQueueAllocate(grand.container)
1373 }
1374 }
1375
1376 // Keep sibling subtree visible
1377 if sibling.container != 0 {
1378 webkit.WidgetShow(sibling.container)
1379 }
1380
1381 // Clean up parent node references
1382 parent.left = nil
1383 parent.right = nil
1384 node.parent = nil
1385
1386 // Focus the target pane
1387 if focusTarget != nil && focusTarget != node {
1388 wm.focusNode(focusTarget)
1389 }
1390
1391 // Destroy the webview and detach hover AFTER all hierarchy changes are complete
1392 ensureCleanup()
1393 wm.detachHover(node)
1394 switch {
1395 case node.isPopup:
1396 if err := node.pane.webView.Destroy(); err != nil {
1397 log.Printf("[workspace] failed to destroy popup webview: %v", err)
1398 }
1399 case willBeLastPane:
1400 // This is the last pane, safe to destroy completely
1401 if err := node.pane.webView.Destroy(); err != nil {
1402 log.Printf("[workspace] failed to destroy webview: %v", err)
1403 }
1404 default:
1405 // Multiple panes remain, don't destroy the window - just clean up the webview
1406 log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1407 // TODO: Add a method to destroy just the webview without the window
1408 }
1409
1410 wm.updateMainPane()
1411 // Update CSS classes after pane count changes
1412 wm.ensurePaneBaseClasses()
1413 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1414 return nil
1415}
1416
1417func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
1418 for node != nil && !node.isLeaf {
1419 if node.left != nil {
1420 node = node.left
1421 continue
1422 }
1423 node = node.right
1424 }
1425 return node
1426}
1427
1428// findReplacementRoot finds a suitable replacement when closing the current root pane
1429func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
1430 if wm == nil || wm.root == nil {
1431 return nil
1432 }
1433
1434 // If root is being closed and there are other panes, find a replacement
1435 leaves := wm.collectLeaves()
1436 for _, leaf := range leaves {
1437 if leaf != excludeNode && leaf != nil && leaf.isLeaf {
1438 // Find the topmost ancestor that's not the current root
1439 current := leaf
1440 for current.parent != nil && current.parent != wm.root {
1441 current = current.parent
1442 }
1443
1444 // If this leaf is a direct child of root, or root only has one subtree,
1445 // we can promote this subtree to be the new root
1446 if current.parent == wm.root {
1447 // If the sibling is being excluded, promote this subtree
1448 var sibling *paneNode
1449 if wm.root.left == current {
1450 sibling = wm.root.right
1451 } else {
1452 sibling = wm.root.left
1453 }
1454
1455 if sibling == excludeNode {
1456 // The sibling is being closed, so promote this subtree
1457 return current
1458 }
1459 }
1460
1461 // Otherwise, return the first suitable leaf
1462 return leaf
1463 }
1464 }
1465
1466 return nil
1467}
1468
1469func (wm *WorkspaceManager) updateMainPane() {
1470 if len(wm.app.panes) == 1 {
1471 if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
1472 wm.mainPane = leaf
1473 }
1474 return
1475 }
1476
1477 if wm.mainPane == nil || !wm.mainPane.isLeaf {
1478 if wm.active != nil && wm.active.isLeaf {
1479 wm.mainPane = wm.active
1480 }
1481 }
1482}
1483
1484func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
1485 log.Printf("[workspace] HandlePopup called for URL: %s", url)
1486
1487 // Check for frame type markers added by WebKit layer
1488 isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
1489 isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
1490
1491 // Clean the URL by removing our markers
1492 if isBlankTarget || isPopupTarget {
1493 if isBlankTarget {
1494 url = strings.TrimSuffix(url, "#__dumber_frame_blank")
1495 log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
1496 } else {
1497 url = strings.TrimSuffix(url, "#__dumber_frame_popup")
1498 log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
1499 }
1500 }
1501
1502 if wm == nil || source == nil {
1503 log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
1504 return nil
1505 }
1506
1507 node := wm.viewToNode[source]
1508 if node == nil {
1509 log.Printf("[workspace] popup from unknown webview - allowing native popup")
1510 return nil
1511 }
1512
1513 // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
1514
1515 cfg := wm.app.config
1516 if cfg == nil {
1517 log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
1518 return nil
1519 }
1520
1521 popCfg := cfg.Workspace.Popups
1522 log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
1523
1524 if !popCfg.OpenInNewPane {
1525 log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
1526 return nil
1527 }
1528
1529 // Smart detection path: create temporary view and decide placement once type is known
1530 if popCfg.EnableSmartDetection {
1531 webkitCfg, err := wm.app.buildWebkitConfig()
1532 if err != nil {
1533 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1534 return nil
1535 }
1536 webkitCfg.CreateWindow = false
1537 // Create as related to avoid WindowFeatures crash; we'll decide final placement later
1538 newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1539 if err != nil {
1540 log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
1541 return nil
1542 }
1543
1544 // Register detection callback
1545 newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
1546 wm.RunOnUI(func() {
1547 wm.handleDetectedWindowType(node, newView, url, t, feat)
1548 })
1549 })
1550
1551 // Fallback: if detection never fires, treat as popup as before
1552 go func() {
1553 time.Sleep(1500 * time.Millisecond)
1554 if newView != nil {
1555 wm.RunOnUI(func() {
1556 if wm.viewToNode[newView] == nil { // not yet placed
1557 wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
1558 }
1559 })
1560 }
1561 }()
1562
1563 return newView
1564 }
1565
1566 // Legacy path preserved
1567 webkitCfg, err := wm.app.buildWebkitConfig()
1568 if err != nil {
1569 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1570 return nil
1571 }
1572 webkitCfg.CreateWindow = false
1573 newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1574 if err != nil {
1575 log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
1576 return nil
1577 }
1578
1579 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
1580
1581 // Create a pane for the new WebView
1582 newPane, err := wm.createPane(newView)
1583 if err != nil {
1584 log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1585 return nil
1586 }
1587
1588 // Determine placement direction
1589 direction := strings.ToLower(popCfg.Placement)
1590 if direction == "" {
1591 direction = "right"
1592 }
1593
1594 // Determine target node for splitting
1595 target := node
1596 if !popCfg.FollowPaneContext && wm.active != nil {
1597 target = wm.active
1598 }
1599
1600 // Add the popup pane to the workspace using manual pane insertion
1601 if err := wm.insertPopupPane(target, newPane, direction); err != nil {
1602 log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1603 return nil
1604 }
1605
1606 // Apply different behavior based on target type
1607 if isBlankTarget {
1608 log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
1609 // For _blank targets, just ensure GUI - no popup-specific behavior
1610 } else {
1611 log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
1612 // Mark as popup for auto-close handling (OAuth flows, etc.)
1613 newNode := wm.viewToNode[newView]
1614 if newNode != nil {
1615 newNode.isPopup = true
1616 log.Printf("[workspace] Marked pane as popup for auto-close handling")
1617
1618 // Register close handler for popup auto-close on window.close()
1619 newView.RegisterCloseHandler(func() {
1620 log.Printf("[workspace] Popup requesting close via window.close()")
1621 // Look up the node at close time
1622 if node := wm.viewToNode[newView]; node != nil && node.isPopup {
1623 log.Printf("[workspace] Closing popup pane")
1624 // Brief delay to allow any final redirects to complete
1625 time.AfterFunc(200*time.Millisecond, func() {
1626 webkit.IdleAdd(func() bool {
1627 if err := wm.closePane(node); err != nil {
1628 log.Printf("[workspace] Failed to close popup pane: %v", err)
1629 }
1630 return false
1631 })
1632 })
1633 } else {
1634 log.Printf("[workspace] Could not find popup node for close handler")
1635 }
1636 })
1637 } else {
1638 log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
1639 }
1640 }
1641
1642 // Ensure GUI components are available in the new pane
1643 wm.ensureGUIInPane(newPane)
1644
1645 // Inject GUI components into the popup pane
1646 wm.ensureGUIInPane(newPane)
1647
1648 // Load the URL if provided
1649 if url != "" {
1650 paneType := "popup"
1651 if isBlankTarget {
1652 paneType = "_blank target"
1653 }
1654 log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
1655 if err := newView.LoadURL(url); err != nil {
1656 log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
1657 }
1658 // Ensure the WebView is visible after loading URL
1659 if err := newView.Show(); err != nil {
1660 log.Printf("[workspace] failed to show popup WebView: %v", err)
1661 }
1662 }
1663
1664 if isBlankTarget {
1665 log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
1666 } else {
1667 log.Printf("[workspace] Created popup pane for URL: %s", url)
1668 }
1669 return newView
1670}
1671
1672// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
1673// Note: OAuth detection is now handled by the main-world.js injection script
1674func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
1675 log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
1676 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
1677}
1678
1679func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
1680 if intent == nil {
1681 return
1682 }
1683
1684 features := &webkit.WindowFeatures{}
1685
1686 // Apply dimensions if specified
1687 if intent.Width != nil {
1688 features.Width = *intent.Width
1689 }
1690 if intent.Height != nil {
1691 features.Height = *intent.Height
1692 }
1693
1694 // Apply visibility features based on window type
1695 defaultToolbar := !isPopup
1696 defaultLocation := !isPopup
1697 defaultMenubar := !isPopup
1698
1699 if intent.Toolbar != nil {
1700 features.ToolbarVisible = *intent.Toolbar
1701 } else {
1702 features.ToolbarVisible = defaultToolbar
1703 }
1704
1705 if intent.Location != nil {
1706 features.LocationbarVisible = *intent.Location
1707 } else {
1708 features.LocationbarVisible = defaultLocation
1709 }
1710
1711 if intent.Menubar != nil {
1712 features.MenubarVisible = *intent.Menubar
1713 } else {
1714 features.MenubarVisible = defaultMenubar
1715 }
1716
1717 if intent.Resizable != nil {
1718 features.Resizable = *intent.Resizable
1719 } else {
1720 features.Resizable = true // Usually resizable unless explicitly disabled
1721 }
1722
1723 view.SetWindowFeatures(features)
1724 windowTypeStr := "tab"
1725 if isPopup {
1726 windowTypeStr = "popup"
1727 }
1728 log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
1729 windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
1730}
1731
1732func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1733 log.Printf("[workspace] Handling intent as tab: %s", url)
1734
1735 webkitCfg, err := wm.app.buildWebkitConfig()
1736 if err != nil {
1737 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1738 return nil
1739 }
1740 webkitCfg.CreateWindow = false
1741
1742 newView, err := webkit.NewWebView(webkitCfg)
1743 if err != nil {
1744 log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
1745 return nil
1746 }
1747
1748 newPane, err := wm.createPane(newView)
1749 if err != nil {
1750 log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
1751 return nil
1752 }
1753
1754 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1755 if direction == "" {
1756 direction = "right"
1757 }
1758
1759 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1760 log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
1761 return nil
1762 }
1763
1764 node := wm.viewToNode[newView]
1765 if node != nil {
1766 node.windowType = webkit.WindowTypeTab
1767 node.isRelated = false
1768
1769 // Apply window features from JavaScript intent
1770 wm.applyWindowFeatures(newView, intent, false)
1771 }
1772
1773 wm.ensureGUIInPane(newPane)
1774
1775 if url != "" {
1776 if err := newView.LoadURL(url); err != nil {
1777 log.Printf("[workspace] failed to load tab URL: %v", err)
1778 }
1779 if err := newView.Show(); err != nil {
1780 log.Printf("[workspace] failed to show tab WebView: %v", err)
1781 }
1782 }
1783
1784 log.Printf("[workspace] Created tab pane for URL: %s", url)
1785 return newView
1786}
1787
1788// handleIntentAsPopup creates a related popup pane based on window.open intent
1789func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1790 log.Printf("[workspace] Handling intent as popup: %s", url)
1791
1792 webkitCfg, err := wm.app.buildWebkitConfig()
1793 if err != nil {
1794 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1795 return nil
1796 }
1797 webkitCfg.CreateWindow = false
1798
1799 newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
1800 if err != nil {
1801 log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
1802 return nil
1803 }
1804
1805 // Log the parent-popup WebView ID relationship for OAuth auto-close
1806 parentWebViewID := sourceNode.pane.webView.ID()
1807 popupWebViewID := newView.ID()
1808 log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
1809
1810 // Store popup WebView ID in parent's localStorage for OAuth callback lookup
1811 storeScript := fmt.Sprintf(`
1812 try {
1813 const parentWebViewId = '%s';
1814 const popupWebViewId = '%s';
1815 const popupMapping = {
1816 parentId: parentWebViewId,
1817 popupId: popupWebViewId,
1818 timestamp: Date.now(),
1819 url: '%s'
1820 };
1821 localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
1822 console.log('[workspace] Stored popup mapping:', popupMapping);
1823 } catch(e) {
1824 console.warn('[workspace] Failed to store popup mapping:', e);
1825 }
1826 `, parentWebViewID, popupWebViewID, url)
1827
1828 // Inject into parent WebView so it can find its popup later
1829 if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
1830 log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
1831 }
1832
1833 newPane, err := wm.createPane(newView)
1834 if err != nil {
1835 log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1836 return nil
1837 }
1838
1839 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1840 if direction == "" {
1841 direction = "right"
1842 }
1843
1844 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1845 log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1846 return nil
1847 }
1848
1849 node := wm.viewToNode[newView]
1850 var requestID string
1851 if node != nil {
1852 node.windowType = webkit.WindowTypePopup
1853 node.isRelated = true
1854 node.parentPane = sourceNode
1855 node.isPopup = true
1856 node.autoClose = wm.shouldAutoClose(url)
1857
1858 // Store requestID for deduplication cleanup
1859 if intent != nil {
1860 requestID = intent.RequestID
1861 }
1862
1863 // Apply window features from JavaScript intent
1864 wm.applyWindowFeatures(newView, intent, true)
1865 }
1866
1867 // Register close handler for popup auto-close
1868 newView.RegisterCloseHandler(func() {
1869 log.Printf("[workspace] Popup requesting close via window.close()")
1870
1871 // Clear the RequestID from deduplicator to allow new popups with same ID
1872 if requestID != "" && wm.paneDeduplicator != nil {
1873 wm.paneDeduplicator.ClearRequestID(requestID)
1874 }
1875
1876 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
1877 time.AfterFunc(200*time.Millisecond, func() {
1878 webkit.IdleAdd(func() bool {
1879 if err := wm.closePane(n); err != nil {
1880 log.Printf("[workspace] Failed to close popup pane: %v", err)
1881 }
1882 return false
1883 })
1884 })
1885 }
1886 })
1887
1888 // URL-based auto-close for OAuth popups
1889 if node != nil && node.isPopup && node.autoClose {
1890 wm.registerOAuthAutoClose(newView, url)
1891 }
1892
1893 wm.ensureGUIInPane(newPane)
1894
1895 if url != "" {
1896 if err := newView.LoadURL(url); err != nil {
1897 log.Printf("[workspace] failed to load popup URL: %v", err)
1898 }
1899 if err := newView.Show(); err != nil {
1900 log.Printf("[workspace] failed to show popup WebView: %v", err)
1901 }
1902 }
1903
1904 log.Printf("[workspace] Created popup pane for URL: %s", url)
1905 return newView
1906}
1907
1908// insertIndependentPane inserts a new independent pane next to the source
1909func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
1910 newPane, err := wm.createPane(webView)
1911 if err != nil {
1912 return err
1913 }
1914 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1915 if direction == "" {
1916 direction = "right"
1917 }
1918 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
1919 return err
1920 }
1921 node := wm.viewToNode[webView]
1922 if node != nil {
1923 node.windowType = webkit.WindowTypeTab
1924 node.isRelated = false
1925 }
1926 if url != "" {
1927 _ = webView.LoadURL(url)
1928 }
1929 return nil
1930}
1931
1932// configureRelatedPopup creates a related view and inserts it
1933func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
1934 // Use the WebView that was already created and returned to WebKit
1935 related := webView
1936 newPane, err := wm.createPane(related)
1937 if err != nil {
1938 log.Printf("[workspace] failed to create related popup pane: %v", err)
1939 return
1940 }
1941 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1942 if direction == "" {
1943 direction = "right"
1944 }
1945 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1946 log.Printf("[workspace] failed to insert related popup pane: %v", err)
1947 return
1948 }
1949 node := wm.viewToNode[related]
1950 if node != nil {
1951 node.windowType = webkit.WindowTypePopup
1952 node.windowFeatures = feat
1953 node.isRelated = true
1954 node.parentPane = sourceNode
1955 node.isPopup = true
1956 // Heuristic + config for auto-close intent
1957 node.autoClose = wm.shouldAutoClose(url)
1958 }
1959 // Pipe into existing auto-close flow only for popups (confirmed by detection)
1960 related.RegisterCloseHandler(func() {
1961 log.Printf("[workspace] Popup requesting close via window.close()")
1962 if n := wm.viewToNode[related]; n != nil && n.isPopup {
1963 time.AfterFunc(200*time.Millisecond, func() {
1964 webkit.IdleAdd(func() bool {
1965 if err := wm.closePane(n); err != nil {
1966 log.Printf("[workspace] Failed to close popup pane: %v", err)
1967 }
1968 return false
1969 })
1970 })
1971 }
1972 })
1973
1974 // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
1975 if node != nil && node.isPopup && node.autoClose {
1976 wm.registerOAuthAutoClose(related, url)
1977 }
1978 if url != "" {
1979 _ = related.LoadURL(url)
1980 }
1981}
1982
1983// shouldAutoClose checks simple OAuth-like URL patterns and config flag
1984func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
1985 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
1986
1987 if wm == nil || wm.app == nil || wm.app.config == nil {
1988 log.Printf("[workspace] shouldAutoClose: missing config, returning true")
1989 return true
1990 }
1991 if !wm.app.config.Workspace.Popups.OAuthAutoClose {
1992 log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
1993 return false
1994 }
1995
1996 u := strings.ToLower(url)
1997 log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
1998
1999 // RFC 6749 compliant OAuth 2.0 URL patterns
2000 oauthPatterns := []string{
2001 // Standard OAuth endpoints
2002 "oauth", "authorize", "authorization",
2003 // Standard callback/redirect patterns
2004 "callback", "redirect", "auth/callback",
2005 // OpenID Connect patterns
2006 "oidc", "openid",
2007 // Common OAuth parameter indicators
2008 "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
2009 // Standard OAuth response parameters
2010 "code=", "access_token=", "id_token=", "token_type=",
2011 // Error response parameters
2012 "error=", "error_description=",
2013 }
2014
2015 // Check for OAuth patterns in URL
2016 for _, pattern := range oauthPatterns {
2017 if strings.Contains(u, pattern) {
2018 log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
2019 return true
2020 }
2021 }
2022
2023 log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
2024 return false
2025}
2026
2027// isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close
2028
2029// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
2030func (wm *WorkspaceManager) RunOnUI(fn func()) {
2031 if fn != nil {
2032 fn()
2033 }
2034}
2035
2036// handleDetectedWindowType handles window type detection from smart detection path
2037func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
2038 if wm.viewToNode[webView] != nil {
2039 return // Already placed
2040 }
2041
2042 log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
2043
2044 switch windowType {
2045 case webkit.WindowTypeTab:
2046 // For tabs, create a NEW independent WebView (can't use the related one)
2047 webkitCfg, err := wm.app.buildWebkitConfig()
2048 if err != nil {
2049 log.Printf("[workspace] failed to build webkit config for tab: %v", err)
2050 return
2051 }
2052 webkitCfg.CreateWindow = false
2053
2054 // Create independent WebView like handleIntentAsTab does
2055 independentView, err := webkit.NewWebView(webkitCfg)
2056 if err != nil {
2057 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
2058 return
2059 }
2060
2061 // The related webView was just for detection - we don't use it for tabs
2062 // Insert the new independent view as a tab
2063 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
2064 log.Printf("[workspace] Failed to insert independent pane: %v", err)
2065 }
2066
2067 case webkit.WindowTypePopup:
2068 // For popups, use the related WebView we already created
2069 wm.configureRelatedPopup(sourceNode, webView, url, features)
2070 default:
2071 // Fallback to popup behavior
2072 wm.configureRelatedPopup(sourceNode, webView, url, features)
2073 }
2074}
2075
2076// insertPopupPane inserts a pre-created popup pane into the workspace
2077func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
2078 if target == nil || !target.isLeaf || target.pane == nil {
2079 return errors.New("insert target must be a leaf pane")
2080 }
2081
2082 if newPane == nil || newPane.webView == nil {
2083 return errors.New("new pane missing webview")
2084 }
2085
2086 if handler := newPane.MessageHandler(); handler != nil {
2087 handler.SetWorkspaceObserver(wm)
2088 }
2089
2090 newContainer := newPane.webView.RootWidget()
2091 if newContainer == 0 {
2092 return errors.New("new pane missing container")
2093 }
2094
2095 webkit.WidgetSetHExpand(newContainer, true)
2096 webkit.WidgetSetVExpand(newContainer, true)
2097 webkit.WidgetRealizeInContainer(newContainer)
2098
2099 // Also realize the WebView widget itself for proper popup rendering
2100 webViewWidget := newPane.webView.Widget()
2101 if webViewWidget != 0 {
2102 webkit.WidgetRealizeInContainer(webViewWidget)
2103 }
2104
2105 existingContainer := target.container
2106 if existingContainer == 0 {
2107 return errors.New("existing pane missing container")
2108 }
2109
2110 orientation, existingFirst := mapDirection(direction)
2111 log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
2112
2113 paned := webkit.NewPaned(orientation)
2114 if paned == 0 {
2115 return errors.New("failed to create GtkPaned")
2116 }
2117 webkit.WidgetSetHExpand(paned, true)
2118 webkit.WidgetSetVExpand(paned, true)
2119 webkit.PanedSetResizeStart(paned, true)
2120 webkit.PanedSetResizeEnd(paned, true)
2121
2122 newLeaf := &paneNode{
2123 pane: newPane,
2124 container: newContainer,
2125 isLeaf: true,
2126 }
2127 split := &paneNode{
2128 parent: target.parent,
2129 container: paned,
2130 orientation: orientation,
2131 isLeaf: false,
2132 }
2133
2134 parent := split.parent
2135
2136 // Detach existing container from its current GTK parent before inserting into new paned.
2137 if parent == nil {
2138 // Target is the root - remove it from the window
2139 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
2140 if wm.window != nil {
2141 wm.window.SetChild(0)
2142 }
2143 } else if parent.container != 0 {
2144 // Target has a parent paned - unparent it from there
2145 log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
2146 if parent.left == target {
2147 webkit.PanedSetStartChild(parent.container, 0)
2148 } else if parent.right == target {
2149 webkit.PanedSetEndChild(parent.container, 0)
2150 }
2151 webkit.WidgetQueueAllocate(parent.container)
2152 }
2153
2154 // Add both containers to the new paned
2155 if existingFirst {
2156 split.left = target
2157 split.right = newLeaf
2158 webkit.PanedSetStartChild(paned, existingContainer)
2159 webkit.PanedSetEndChild(paned, newContainer)
2160 log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
2161 } else {
2162 split.left = newLeaf
2163 split.right = target
2164 webkit.PanedSetStartChild(paned, newContainer)
2165 webkit.PanedSetEndChild(paned, existingContainer)
2166 log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
2167 }
2168
2169 target.parent = split
2170 newLeaf.parent = split
2171
2172 if parent == nil {
2173 wm.root = split
2174 if wm.window != nil {
2175 wm.window.SetChild(paned)
2176 webkit.WidgetQueueAllocate(paned)
2177 log.Printf("[workspace] paned set as window child: paned=%#x", paned)
2178 }
2179 } else {
2180 if parent.left == target {
2181 parent.left = split
2182 webkit.PanedSetStartChild(parent.container, paned)
2183 } else if parent.right == target {
2184 parent.right = split
2185 webkit.PanedSetEndChild(parent.container, paned)
2186 }
2187 webkit.WidgetQueueAllocate(parent.container)
2188 webkit.WidgetQueueAllocate(paned)
2189 log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
2190 }
2191
2192 webkit.WidgetShow(paned)
2193
2194 wm.viewToNode[newPane.webView] = newLeaf
2195 wm.ensureHover(newLeaf)
2196 wm.ensureHover(target)
2197 wm.app.panes = append(wm.app.panes, newPane)
2198 if newPane.zoomController != nil {
2199 newPane.zoomController.ApplyInitialZoom()
2200 }
2201
2202 // Update CSS classes for all panes now that we have multiple panes
2203 wm.ensurePaneBaseClasses()
2204
2205 webkit.IdleAdd(func() bool {
2206 if newContainer != 0 {
2207 webkit.WidgetShow(newContainer)
2208 }
2209 wm.focusNode(newLeaf)
2210 return false
2211 })
2212
2213 return nil
2214}
2215
2216// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
2217// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
2218// This prevents duplicate GUI injection that was causing duplicate log messages
2219func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
2220 if pane == nil {
2221 return
2222 }
2223
2224 // GUI is already injected globally via WebKit's enableUserContentManager
2225 // Just mark the pane as having GUI to prevent unnecessary calls
2226 if !pane.HasGUI() {
2227 pane.SetHasGUI(true)
2228 pane.SetGUIComponent("manager", true)
2229 pane.SetGUIComponent("omnibox", true)
2230 }
2231}
2232
2233func mapDirection(direction string) (webkit.Orientation, bool) {
2234 switch direction {
2235 case "left":
2236 return webkit.OrientationHorizontal, false
2237 case "up":
2238 return webkit.OrientationVertical, false
2239 case "down":
2240 return webkit.OrientationVertical, true
2241 default:
2242 return webkit.OrientationHorizontal, true
2243 }
2244}