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// registerWorkspaceShortcuts registers global workspace navigation shortcuts on the given webView.
71func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) {
72 if wm == nil || webView == nil {
73 return
74 }
75
76 registerFocusMove := func(key, dir string) {
77 _ = webView.RegisterKeyboardShortcut(key, func() {
78 log.Printf("[workspace] %s callback triggered on webView=%p", key, webView)
79 if wm.app == nil {
80 log.Printf("[workspace] %s rejected: app is nil", key)
81 return
82 }
83
84 // Only allow the active webview to handle workspace navigation shortcuts
85 if wm.app.activePane == nil || wm.app.activePane.webView != webView {
86 log.Printf("[workspace] %s rejected: not active pane (active=%p, caller=%p)",
87 key, wm.app.activePane.webView, webView)
88 return
89 }
90
91 log.Printf("[workspace] %s calling FocusNeighbor(%s)", key, dir)
92 if wm.FocusNeighbor(dir) {
93 log.Printf("Shortcut: focus pane %s", dir)
94 } else {
95 log.Printf("[workspace] FocusNeighbor(%s) returned false", dir)
96 }
97 })
98 }
99
100 // Pane navigation shortcuts
101 registerFocusMove("alt+ArrowLeft", "left")
102 registerFocusMove("alt+ArrowRight", "right")
103 registerFocusMove("alt+ArrowUp", "up")
104 registerFocusMove("alt+ArrowDown", "down")
105 registerFocusMove("cmdorctrl+ArrowUp", "up")
106 registerFocusMove("cmdorctrl+ArrowDown", "down")
107
108 // Zoom shortcuts are now handled by global window shortcuts in window_shortcuts.go
109 // for consistency with other global shortcuts like Ctrl+L, Ctrl+F, etc.
110
111 // Reduced logging: only log shortcuts registration during initialization, not on every hover
112 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
113 log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)
114 }
115}
116
117// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
118func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
119 manager := &WorkspaceManager{
120 app: app,
121 window: rootPane.webView.Window(),
122 viewToNode: make(map[*webkit.WebView]*paneNode),
123 lastSplitMsg: make(map[*webkit.WebView]time.Time),
124 lastExitMsg: make(map[*webkit.WebView]time.Time),
125 paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
126 }
127 manager.createWebViewFn = func() (*webkit.WebView, error) {
128 if manager.app == nil {
129 return nil, errors.New("workspace manager missing app reference")
130 }
131 cfg, err := manager.app.buildWebkitConfig()
132 if err != nil {
133 return nil, err
134 }
135 // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization
136 // This prevents WindowFeatures optional crashes when WebKit accesses popup properties
137 cfg.CreateWindow = true
138 return webkit.NewWebView(cfg)
139 }
140 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
141 if manager.app == nil {
142 return nil, errors.New("workspace manager missing app reference")
143 }
144 return manager.app.createPaneForView(view)
145 }
146 manager.ensureStyles()
147
148 rootContainer := rootPane.webView.RootWidget()
149 root := &paneNode{
150 pane: rootPane,
151 container: rootContainer,
152 isLeaf: true,
153 }
154 manager.root = root
155 manager.active = root
156 manager.mainPane = root
157 manager.viewToNode[rootPane.webView] = root
158 manager.ensureHover(root)
159
160 if handler := rootPane.MessageHandler(); handler != nil {
161 handler.SetWorkspaceObserver(manager)
162 }
163
164 app.workspace = manager
165 manager.focusNode(root)
166 manager.registerWorkspaceShortcuts(root.pane.webView)
167 // Ensure initial CSS classes are applied
168 manager.ensurePaneBaseClasses()
169 return manager
170}
171
172// OnWorkspaceMessage implements messaging.WorkspaceObserver.
173func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
174 node := wm.viewToNode[source]
175 if node == nil {
176 log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
177 return
178 }
179
180 switch event := strings.ToLower(msg.Event); event {
181 case "pane-mode-entered":
182 // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
183 wm.paneMutex.Lock()
184 defer wm.paneMutex.Unlock()
185
186 // Check if pane mode was already entered recently (within 200ms debounce window)
187 if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
188 log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
189 time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
190 return
191 }
192
193 // Check if pane mode is already active from a different source
194 if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
195 log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
196 wm.paneModeSource, source)
197 return
198 }
199
200 log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
201 wm.paneModeActive = true
202 wm.paneModeSource = source
203 wm.lastPaneModeEntry = time.Now()
204 wm.focusNode(node)
205 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
206 // Debounce pane-mode-exited events to prevent duplicate focus calls
207 if event == "pane-mode-exited" {
208 if last, ok := wm.lastExitMsg[source]; ok {
209 if time.Since(last) < 100*time.Millisecond {
210 log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
211 return
212 }
213 }
214 wm.lastExitMsg[source] = time.Now()
215 }
216
217 wm.paneMutex.Lock()
218 wm.paneModeActive = false
219 wm.paneModeSource = nil
220 wm.paneMutex.Unlock()
221
222 wm.focusNode(node)
223 case "pane-close":
224 if !wm.paneModeActive {
225 log.Printf("[workspace] close requested outside pane mode; ignoring")
226 break
227 }
228
229 wm.paneMutex.Lock()
230 wm.paneModeActive = false
231 wm.paneModeSource = nil
232 wm.paneMutex.Unlock()
233
234 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
235 wm.closeCurrentPane()
236 case "pane-split":
237 wm.focusNode(node)
238 direction := strings.ToLower(msg.Direction)
239 if direction == "" {
240 direction = "right"
241 }
242 if last, ok := wm.lastSplitMsg[source]; ok {
243 if time.Since(last) < 200*time.Millisecond {
244 log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
245 return
246 }
247 }
248 if wm.splitting {
249 log.Printf("[workspace] split ignored: operation already in progress")
250 return
251 }
252 wm.splitting = true
253 wm.lastSplitMsg[source] = time.Now()
254 newNode, err := wm.splitNode(node, direction)
255 if err != nil {
256 log.Printf("[workspace] split failed: %v", err)
257 wm.splitting = false
258 return
259 }
260 wm.clonePaneState(node, newNode)
261 wm.splitting = false
262 case "create-pane":
263 log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
264
265 if msg.URL == "" {
266 log.Printf("[workspace] create-pane: empty URL, ignoring")
267 break
268 }
269
270 // NEW: Get WebView ID for deduplication
271 webViewID := "unknown"
272 if source != nil {
273 // Try to get a unique identifier for the WebView
274 webViewID = fmt.Sprintf("%p", source)
275 }
276
277 // NEW: Create intent for deduplication check
278 intent := &messaging.WindowIntent{
279 URL: msg.URL,
280 WindowType: msg.Action,
281 Timestamp: time.Now().UnixMilli(),
282 RequestID: msg.RequestID,
283 UserTriggered: true,
284 }
285
286 // NEW: Check for duplicates
287 if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
288 log.Printf("[workspace] create-pane BLOCKED: %s", reason)
289 break
290 }
291
292 // Use the existing methods to handle tab vs popup creation
293 switch strings.ToLower(msg.Action) {
294 case "tab":
295 newView := wm.handleIntentAsTab(node, msg.URL, intent)
296 if newView != nil {
297 log.Printf("[workspace] create-pane: tab created successfully")
298 } else {
299 log.Printf("[workspace] create-pane: failed to create tab")
300 }
301 case "popup":
302 newView := wm.handleIntentAsPopup(node, msg.URL, intent)
303 if newView != nil {
304 log.Printf("[workspace] create-pane: popup created successfully")
305 } else {
306 log.Printf("[workspace] create-pane: failed to create popup")
307 }
308 default:
309 log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
310 newView := wm.handleIntentAsTab(node, msg.URL, intent)
311 if newView != nil {
312 log.Printf("[workspace] create-pane: default tab created successfully")
313 } else {
314 log.Printf("[workspace] create-pane: failed to create default tab")
315 }
316 }
317 case "close-popup":
318 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
319
320 if msg.WebViewID == "" {
321 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
322 break
323 }
324
325 // Find the popup pane by webview ID
326 var targetNode *paneNode
327 for webView, node := range wm.viewToNode {
328 if webView != nil && webView.ID() == msg.WebViewID {
329 targetNode = node
330 break
331 }
332 }
333
334 if targetNode == nil {
335 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
336 break
337 }
338
339 if !targetNode.isPopup {
340 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
341 break
342 }
343
344 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
345
346 // Close the popup pane
347 if err := wm.closePane(targetNode); err != nil {
348 log.Printf("[workspace] Failed to close popup pane: %v", err)
349 } else {
350 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
351 }
352 default:
353 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
354 }
355}
356
357func (wm *WorkspaceManager) focusNode(node *paneNode) {
358 if node == nil || !node.isLeaf || node.pane == nil || node.pane.webView == nil {
359 return
360 }
361
362 // Don't focus a node that's not in our viewToNode map (likely destroyed)
363 if _, exists := wm.viewToNode[node.pane.webView]; !exists {
364 log.Printf("[workspace] focusNode: skipping destroyed/unknown webView=%p", node.pane.webView)
365 return
366 }
367
368 previous := wm.active
369 var previousContainer uintptr
370 var previousWebView *webkit.WebView
371 if previous != nil && previous != node && previous.pane != nil && previous.pane.webView != nil {
372 previousContainer = previous.container
373 previousWebView = previous.pane.webView
374 detail := map[string]any{
375 "active": false,
376 "webview": fmt.Sprintf("%p", previous.pane.webView),
377 "paneId": previous.pane.ID(),
378 "timestamp": time.Now().UnixMilli(),
379 }
380 if err := previous.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
381 log.Printf("[workspace] failed to notify focus loss: %v", err)
382 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
383 log.Printf("[workspace] notified focus loss for pane %s", previous.pane.ID())
384 }
385 }
386
387 // Set previously active WebView as inactive
388 if previousWebView != nil && previousWebView != node.pane.webView {
389 previousWebView.SetActive(false)
390 }
391
392 wm.active = node
393 wm.app.activePane = node.pane
394 wm.app.webView = node.pane.webView
395 wm.app.zoomController = node.pane.zoomController
396 wm.app.navigationController = node.pane.navigationController
397 wm.app.clipboardController = node.pane.clipboardController
398 wm.app.messageHandler = node.pane.messageHandler
399 wm.app.shortcutHandler = node.pane.shortcutHandler
400
401 // Set this WebView as active
402 if node.pane != nil && node.pane.webView != nil {
403 node.pane.webView.SetActive(true)
404 }
405
406 // Re-register workspace navigation shortcuts on the newly focused webView
407 wm.registerWorkspaceShortcuts(node.pane.webView)
408
409 if handler := node.pane.messageHandler; handler != nil {
410 handler.SetWorkspaceObserver(wm)
411 }
412
413 if wm.app.browserService != nil {
414 wm.app.browserService.AttachWebView(node.pane.webView)
415 }
416
417 // Ensure all panes have proper base classes
418 wm.ensurePaneBaseClasses()
419
420 container := node.container
421 viewWidget := node.pane.webView.Widget()
422 if container != 0 && container != previousContainer {
423 // Remove active class from previous container to ensure only one pane is visually active
424 if previousContainer != 0 {
425 webkit.WidgetRemoveCSSClass(previousContainer, activePaneClass)
426 }
427
428 // Add active class to current container (border visibility controlled by CSS multi-pane rules)
429 webkit.WidgetAddCSSClass(container, activePaneClass)
430 if !webkit.WidgetIsValid(container) {
431 log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
432 return
433 }
434 webkit.IdleAdd(func() bool {
435 if !webkit.WidgetIsValid(container) {
436 log.Printf("[workspace] focus aborted during idle: container invalid widget=%#x", container)
437 return false
438 }
439 parent := webkit.WidgetGetParent(container)
440 if parent != 0 {
441 webkit.WidgetRealizeInContainer(container)
442 if viewWidget != 0 {
443 webkit.WidgetGrabFocus(viewWidget)
444 }
445 // Consolidated focus operation log
446 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
447 log.Printf("[workspace] focus operations completed: container=%#x parent=%#x viewWidget=%#x", container, parent, viewWidget)
448 }
449 } else {
450 log.Printf("[workspace] focus deferred: widget not parented")
451 }
452 return false // Remove idle callback
453 })
454 }
455
456 if node.pane != nil && node.pane.webView != nil && node.pane.webView != previousWebView {
457 // Update pane focus time
458 node.pane.UpdateLastFocus()
459
460 // Enhanced focus event with pane ID and GUI status
461 detail := map[string]any{
462 "active": true,
463 "webview": fmt.Sprintf("%p", node.pane.webView),
464 "paneId": node.pane.ID(),
465 "hasGUI": node.pane.HasGUI(),
466 "timestamp": time.Now().UnixMilli(),
467 }
468
469 // Dispatch focus event to new active pane
470 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
471 log.Printf("[workspace] failed to notify focus gain: %v", err)
472 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
473 log.Printf("[workspace] notified focus gain for pane %s", node.pane.ID())
474 }
475
476 // Lazy-load GUI components if first focus
477 if !node.pane.HasGUI() {
478 wm.ensureGUIInPane(node.pane)
479 }
480 }
481}
482
483// generateActivePaneCSS generates the CSS for workspace panes based on config
484func (wm *WorkspaceManager) generateActivePaneCSS() string {
485 cfg := config.Get()
486 styling := cfg.Workspace.Styling
487
488 return fmt.Sprintf(`.workspace-pane {
489 border: %dpx solid transparent;
490 transition: border-color %dms ease-in-out;
491 border-radius: %dpx;
492 }
493
494 .workspace-pane.workspace-multi-pane {
495 border: %dpx solid transparent;
496 border-radius: %dpx;
497 }
498
499 .workspace-pane.workspace-multi-pane.workspace-pane-active {
500 border-color: %s;
501 }`,
502 styling.BorderWidth,
503 styling.TransitionDuration,
504 styling.BorderRadius,
505 styling.BorderWidth,
506 styling.BorderRadius,
507 styling.BorderColor,
508 )
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 remaining := len(wm.app.panes)
1090 willBeLastPane := remaining <= 1
1091 if willBeLastPane && wm.root == node {
1092 log.Printf("[workspace] closing final pane; exiting browser")
1093 wm.detachHover(node)
1094 if err := node.pane.webView.Destroy(); err != nil {
1095 log.Printf("[workspace] failed to destroy webview: %v", err)
1096 }
1097 webkit.QuitMainLoop()
1098 return nil
1099 }
1100
1101 parent := node.parent
1102 if parent == nil {
1103 // This is the root pane (no parent in tree structure)
1104 if node != wm.root {
1105 return errors.New("inconsistent state: node has no parent but is not root")
1106 }
1107
1108 // Check if we can find a replacement root
1109 replacement := wm.findReplacementRoot(node)
1110 if replacement == nil {
1111 // No other panes exist, this is the final pane
1112 log.Printf("[workspace] closing final pane; exiting browser")
1113 wm.detachHover(node)
1114 if err := node.pane.webView.Destroy(); err != nil {
1115 log.Printf("[workspace] failed to destroy webview: %v", err)
1116 }
1117 webkit.QuitMainLoop()
1118 return nil
1119 }
1120
1121 // We have a replacement - delegate root status and close this pane
1122 log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
1123
1124 // Clean up references first
1125 delete(wm.viewToNode, node.pane.webView)
1126 delete(wm.lastSplitMsg, node.pane.webView)
1127 delete(wm.lastExitMsg, node.pane.webView)
1128
1129 for i, pane := range wm.app.panes {
1130 if pane == node.pane {
1131 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1132 break
1133 }
1134 }
1135
1136 if wm.mainPane == node {
1137 wm.mainPane = nil
1138 }
1139
1140 // Clear current active if it's the node being closed
1141 if wm.active == node {
1142 wm.active = nil
1143 }
1144
1145 // Set replacement as new root
1146 wm.root = replacement
1147 replacement.parent = nil
1148
1149 // Replace window child directly (GTK handles reparenting automatically)
1150 if wm.window != nil {
1151 wm.window.SetChild(replacement.container)
1152 if replacement.container != 0 {
1153 webkit.WidgetQueueAllocate(replacement.container)
1154 webkit.WidgetShow(replacement.container)
1155 }
1156 }
1157
1158 // Focus a suitable pane
1159 focusTarget := wm.leftmostLeaf(replacement)
1160 if focusTarget != nil {
1161 wm.focusNode(focusTarget)
1162 }
1163
1164 // Destroy the webview and detach hover AFTER rearranging hierarchy
1165 // Only destroy the webview if this is the final pane, otherwise just clean up
1166 wm.detachHover(node)
1167 if willBeLastPane {
1168 // This is the last pane, safe to destroy completely
1169 if err := node.pane.webView.Destroy(); err != nil {
1170 log.Printf("[workspace] failed to destroy webview: %v", err)
1171 }
1172 } else {
1173 // Multiple panes remain, don't destroy the window - just clean up the webview
1174 log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1175 // TODO: Add a method to destroy just the webview without the window
1176 }
1177
1178 wm.updateMainPane()
1179 // Update CSS classes after pane count changes
1180 wm.ensurePaneBaseClasses()
1181 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
1182 return nil
1183 }
1184
1185 var sibling *paneNode
1186 if parent.left == node {
1187 sibling = parent.right
1188 } else {
1189 sibling = parent.left
1190 }
1191 if sibling == nil {
1192 return errors.New("pane close failed: missing sibling")
1193 }
1194
1195 log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
1196
1197 // Find focus target before modifying the tree structure
1198 focusTarget := wm.leftmostLeaf(sibling)
1199 if focusTarget == nil {
1200 focusTarget = wm.leftmostLeaf(wm.root)
1201 }
1202
1203 // Clean up references first
1204 delete(wm.viewToNode, node.pane.webView)
1205 delete(wm.lastSplitMsg, node.pane.webView)
1206 delete(wm.lastExitMsg, node.pane.webView)
1207
1208 for i, pane := range wm.app.panes {
1209 if pane == node.pane {
1210 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1211 break
1212 }
1213 }
1214
1215 if wm.mainPane == node {
1216 wm.mainPane = nil
1217 }
1218
1219 // Clear current active if it's the node being closed
1220 if wm.active == node {
1221 wm.active = nil
1222 }
1223
1224 grand := parent.parent
1225 if grand == nil {
1226 // Parent is the root node. Promote sibling to become the new root.
1227 // The sibling can be either a leaf (when only 2 panes total) or a branch (when more panes exist)
1228 log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1229 wm.root = sibling
1230 sibling.parent = nil
1231
1232 // Unparent the sibling from the paned first, then set it as window child
1233 if wm.window != nil {
1234 // GTK requires explicit unparenting before reparenting
1235 if parent.left == sibling {
1236 webkit.PanedSetStartChild(parent.container, 0)
1237 } else {
1238 webkit.PanedSetEndChild(parent.container, 0)
1239 }
1240 // Now we can safely set it as window child
1241 wm.window.SetChild(sibling.container)
1242 if sibling.container != 0 {
1243 webkit.WidgetQueueAllocate(sibling.container)
1244 }
1245 }
1246 } else {
1247 // Parent has a grandparent, so promote sibling to take parent's place
1248 log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1249
1250 // First unparent the sibling from its current parent to avoid GTK-CRITICAL errors
1251 if parent.container != 0 && !parent.isLeaf {
1252 if parent.left == sibling {
1253 webkit.PanedSetStartChild(parent.container, 0)
1254 } else if parent.right == sibling {
1255 webkit.PanedSetEndChild(parent.container, 0)
1256 }
1257 }
1258
1259 // Now safely reparent the sibling to the grandparent
1260 sibling.parent = grand
1261 if grand.container != 0 && !grand.isLeaf {
1262 if grand.left == parent {
1263 grand.left = sibling
1264 webkit.PanedSetStartChild(grand.container, sibling.container)
1265 } else if grand.right == parent {
1266 grand.right = sibling
1267 webkit.PanedSetEndChild(grand.container, sibling.container)
1268 }
1269 webkit.WidgetQueueAllocate(grand.container)
1270 }
1271 }
1272
1273 // Keep sibling subtree visible
1274 if sibling.container != 0 {
1275 webkit.WidgetShow(sibling.container)
1276 }
1277
1278 // Clean up parent node references
1279 parent.left = nil
1280 parent.right = nil
1281 node.parent = nil
1282
1283 // Focus the target pane
1284 if focusTarget != nil && focusTarget != node {
1285 wm.focusNode(focusTarget)
1286 }
1287
1288 // Destroy the webview and detach hover AFTER all hierarchy changes are complete
1289 // Only destroy the webview if this is the final pane, otherwise just clean up
1290 wm.detachHover(node)
1291 if willBeLastPane {
1292 // This is the last pane, safe to destroy completely
1293 if err := node.pane.webView.Destroy(); err != nil {
1294 log.Printf("[workspace] failed to destroy webview: %v", err)
1295 }
1296 } else {
1297 // Multiple panes remain, don't destroy the window - just clean up the webview
1298 log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1299 // TODO: Add a method to destroy just the webview without the window
1300 }
1301
1302 wm.updateMainPane()
1303 // Update CSS classes after pane count changes
1304 wm.ensurePaneBaseClasses()
1305 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1306 return nil
1307}
1308
1309func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
1310 for node != nil && !node.isLeaf {
1311 if node.left != nil {
1312 node = node.left
1313 continue
1314 }
1315 node = node.right
1316 }
1317 return node
1318}
1319
1320// findReplacementRoot finds a suitable replacement when closing the current root pane
1321func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
1322 if wm == nil || wm.root == nil {
1323 return nil
1324 }
1325
1326 // If root is being closed and there are other panes, find a replacement
1327 leaves := wm.collectLeaves()
1328 for _, leaf := range leaves {
1329 if leaf != excludeNode && leaf != nil && leaf.isLeaf {
1330 // Find the topmost ancestor that's not the current root
1331 current := leaf
1332 for current.parent != nil && current.parent != wm.root {
1333 current = current.parent
1334 }
1335
1336 // If this leaf is a direct child of root, or root only has one subtree,
1337 // we can promote this subtree to be the new root
1338 if current.parent == wm.root {
1339 // If the sibling is being excluded, promote this subtree
1340 var sibling *paneNode
1341 if wm.root.left == current {
1342 sibling = wm.root.right
1343 } else {
1344 sibling = wm.root.left
1345 }
1346
1347 if sibling == excludeNode {
1348 // The sibling is being closed, so promote this subtree
1349 return current
1350 }
1351 }
1352
1353 // Otherwise, return the first suitable leaf
1354 return leaf
1355 }
1356 }
1357
1358 return nil
1359}
1360
1361func (wm *WorkspaceManager) updateMainPane() {
1362 if len(wm.app.panes) == 1 {
1363 if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
1364 wm.mainPane = leaf
1365 }
1366 return
1367 }
1368
1369 if wm.mainPane == nil || !wm.mainPane.isLeaf {
1370 if wm.active != nil && wm.active.isLeaf {
1371 wm.mainPane = wm.active
1372 }
1373 }
1374}
1375
1376func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
1377 log.Printf("[workspace] HandlePopup called for URL: %s", url)
1378
1379 // Check for frame type markers added by WebKit layer
1380 isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
1381 isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
1382
1383 // Clean the URL by removing our markers
1384 if isBlankTarget || isPopupTarget {
1385 if isBlankTarget {
1386 url = strings.TrimSuffix(url, "#__dumber_frame_blank")
1387 log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
1388 } else {
1389 url = strings.TrimSuffix(url, "#__dumber_frame_popup")
1390 log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
1391 }
1392 }
1393
1394 if wm == nil || source == nil {
1395 log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
1396 return nil
1397 }
1398
1399 node := wm.viewToNode[source]
1400 if node == nil {
1401 log.Printf("[workspace] popup from unknown webview - allowing native popup")
1402 return nil
1403 }
1404
1405 // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
1406
1407 cfg := wm.app.config
1408 if cfg == nil {
1409 log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
1410 return nil
1411 }
1412
1413 popCfg := cfg.Workspace.Popups
1414 log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
1415
1416 if !popCfg.OpenInNewPane {
1417 log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
1418 return nil
1419 }
1420
1421 // Smart detection path: create temporary view and decide placement once type is known
1422 if popCfg.EnableSmartDetection {
1423 webkitCfg, err := wm.app.buildWebkitConfig()
1424 if err != nil {
1425 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1426 return nil
1427 }
1428 webkitCfg.CreateWindow = false
1429 // Create as related to avoid WindowFeatures crash; we'll decide final placement later
1430 newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1431 if err != nil {
1432 log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
1433 return nil
1434 }
1435
1436 // Register detection callback
1437 newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
1438 wm.RunOnUI(func() {
1439 wm.handleDetectedWindowType(node, newView, url, t, feat)
1440 })
1441 })
1442
1443 // Fallback: if detection never fires, treat as popup as before
1444 go func() {
1445 time.Sleep(1500 * time.Millisecond)
1446 if newView != nil {
1447 wm.RunOnUI(func() {
1448 if wm.viewToNode[newView] == nil { // not yet placed
1449 wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
1450 }
1451 })
1452 }
1453 }()
1454
1455 return newView
1456 }
1457
1458 // Legacy path preserved
1459 webkitCfg, err := wm.app.buildWebkitConfig()
1460 if err != nil {
1461 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1462 return nil
1463 }
1464 webkitCfg.CreateWindow = false
1465 newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1466 if err != nil {
1467 log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
1468 return nil
1469 }
1470
1471 // Register workspace navigation shortcuts on popup WebView before creating pane
1472 // This ensures shortcuts are available immediately when the WebView is ready
1473 wm.registerWorkspaceShortcuts(newView)
1474 log.Printf("[workspace] Registered workspace shortcuts for new WebView")
1475
1476 // Create a pane for the new WebView
1477 newPane, err := wm.createPane(newView)
1478 if err != nil {
1479 log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1480 return nil
1481 }
1482
1483 // Determine placement direction
1484 direction := strings.ToLower(popCfg.Placement)
1485 if direction == "" {
1486 direction = "right"
1487 }
1488
1489 // Determine target node for splitting
1490 target := node
1491 if !popCfg.FollowPaneContext && wm.active != nil {
1492 target = wm.active
1493 }
1494
1495 // Add the popup pane to the workspace using manual pane insertion
1496 if err := wm.insertPopupPane(target, newPane, direction); err != nil {
1497 log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1498 return nil
1499 }
1500
1501 // Apply different behavior based on target type
1502 if isBlankTarget {
1503 log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
1504 // For _blank targets, just ensure GUI - no popup-specific behavior
1505 } else {
1506 log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
1507 // Mark as popup for auto-close handling (OAuth flows, etc.)
1508 newNode := wm.viewToNode[newView]
1509 if newNode != nil {
1510 newNode.isPopup = true
1511 log.Printf("[workspace] Marked pane as popup for auto-close handling")
1512
1513 // Register close handler for popup auto-close on window.close()
1514 newView.RegisterCloseHandler(func() {
1515 log.Printf("[workspace] Popup requesting close via window.close()")
1516 // Look up the node at close time
1517 if node := wm.viewToNode[newView]; node != nil && node.isPopup {
1518 log.Printf("[workspace] Closing popup pane")
1519 // Brief delay to allow any final redirects to complete
1520 time.AfterFunc(200*time.Millisecond, func() {
1521 webkit.IdleAdd(func() bool {
1522 if err := wm.closePane(node); err != nil {
1523 log.Printf("[workspace] Failed to close popup pane: %v", err)
1524 }
1525 return false
1526 })
1527 })
1528 } else {
1529 log.Printf("[workspace] Could not find popup node for close handler")
1530 }
1531 })
1532 } else {
1533 log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
1534 }
1535 }
1536
1537 // Ensure GUI components are available in the new pane
1538 wm.ensureGUIInPane(newPane)
1539
1540 // Inject GUI components into the popup pane
1541 wm.ensureGUIInPane(newPane)
1542
1543 // Load the URL if provided
1544 if url != "" {
1545 paneType := "popup"
1546 if isBlankTarget {
1547 paneType = "_blank target"
1548 }
1549 log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
1550 if err := newView.LoadURL(url); err != nil {
1551 log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
1552 }
1553 // Ensure the WebView is visible after loading URL
1554 if err := newView.Show(); err != nil {
1555 log.Printf("[workspace] failed to show popup WebView: %v", err)
1556 }
1557 }
1558
1559 if isBlankTarget {
1560 log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
1561 } else {
1562 log.Printf("[workspace] Created popup pane for URL: %s", url)
1563 }
1564 return newView
1565}
1566
1567// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
1568// Note: OAuth detection is now handled by the main-world.js injection script
1569func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
1570 log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
1571 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
1572}
1573
1574func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
1575 if intent == nil {
1576 return
1577 }
1578
1579 features := &webkit.WindowFeatures{}
1580
1581 // Apply dimensions if specified
1582 if intent.Width != nil {
1583 features.Width = *intent.Width
1584 }
1585 if intent.Height != nil {
1586 features.Height = *intent.Height
1587 }
1588
1589 // Apply visibility features based on window type
1590 defaultToolbar := !isPopup
1591 defaultLocation := !isPopup
1592 defaultMenubar := !isPopup
1593
1594 if intent.Toolbar != nil {
1595 features.ToolbarVisible = *intent.Toolbar
1596 } else {
1597 features.ToolbarVisible = defaultToolbar
1598 }
1599
1600 if intent.Location != nil {
1601 features.LocationbarVisible = *intent.Location
1602 } else {
1603 features.LocationbarVisible = defaultLocation
1604 }
1605
1606 if intent.Menubar != nil {
1607 features.MenubarVisible = *intent.Menubar
1608 } else {
1609 features.MenubarVisible = defaultMenubar
1610 }
1611
1612 if intent.Resizable != nil {
1613 features.Resizable = *intent.Resizable
1614 } else {
1615 features.Resizable = true // Usually resizable unless explicitly disabled
1616 }
1617
1618 view.SetWindowFeatures(features)
1619 windowTypeStr := "tab"
1620 if isPopup {
1621 windowTypeStr = "popup"
1622 }
1623 log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
1624 windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
1625}
1626
1627func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1628 log.Printf("[workspace] Handling intent as tab: %s", url)
1629
1630 webkitCfg, err := wm.app.buildWebkitConfig()
1631 if err != nil {
1632 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1633 return nil
1634 }
1635 webkitCfg.CreateWindow = false
1636
1637 newView, err := webkit.NewWebView(webkitCfg)
1638 if err != nil {
1639 log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
1640 return nil
1641 }
1642
1643 newPane, err := wm.createPane(newView)
1644 if err != nil {
1645 log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
1646 return nil
1647 }
1648
1649 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1650 if direction == "" {
1651 direction = "right"
1652 }
1653
1654 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1655 log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
1656 return nil
1657 }
1658
1659 node := wm.viewToNode[newView]
1660 if node != nil {
1661 node.windowType = webkit.WindowTypeTab
1662 node.isRelated = false
1663
1664 // Apply window features from JavaScript intent
1665 wm.applyWindowFeatures(newView, intent, false)
1666 }
1667
1668 wm.ensureGUIInPane(newPane)
1669
1670 if url != "" {
1671 if err := newView.LoadURL(url); err != nil {
1672 log.Printf("[workspace] failed to load tab URL: %v", err)
1673 }
1674 if err := newView.Show(); err != nil {
1675 log.Printf("[workspace] failed to show tab WebView: %v", err)
1676 }
1677 }
1678
1679 log.Printf("[workspace] Created tab pane for URL: %s", url)
1680 return newView
1681}
1682
1683// handleIntentAsPopup creates a related popup pane based on window.open intent
1684func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1685 log.Printf("[workspace] Handling intent as popup: %s", url)
1686
1687 webkitCfg, err := wm.app.buildWebkitConfig()
1688 if err != nil {
1689 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1690 return nil
1691 }
1692 webkitCfg.CreateWindow = false
1693
1694 newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
1695 if err != nil {
1696 log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
1697 return nil
1698 }
1699
1700 // Log the parent-popup WebView ID relationship for OAuth auto-close
1701 parentWebViewID := sourceNode.pane.webView.ID()
1702 popupWebViewID := newView.ID()
1703 log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
1704
1705 // Store popup WebView ID in parent's localStorage for OAuth callback lookup
1706 storeScript := fmt.Sprintf(`
1707 try {
1708 const parentWebViewId = '%s';
1709 const popupWebViewId = '%s';
1710 const popupMapping = {
1711 parentId: parentWebViewId,
1712 popupId: popupWebViewId,
1713 timestamp: Date.now(),
1714 url: '%s'
1715 };
1716 localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
1717 console.log('[workspace] Stored popup mapping:', popupMapping);
1718 } catch(e) {
1719 console.warn('[workspace] Failed to store popup mapping:', e);
1720 }
1721 `, parentWebViewID, popupWebViewID, url)
1722
1723 // Inject into parent WebView so it can find its popup later
1724 if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
1725 log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
1726 }
1727
1728 newPane, err := wm.createPane(newView)
1729 if err != nil {
1730 log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1731 return nil
1732 }
1733
1734 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1735 if direction == "" {
1736 direction = "right"
1737 }
1738
1739 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1740 log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1741 return nil
1742 }
1743
1744 node := wm.viewToNode[newView]
1745 var requestID string
1746 if node != nil {
1747 node.windowType = webkit.WindowTypePopup
1748 node.isRelated = true
1749 node.parentPane = sourceNode
1750 node.isPopup = true
1751 node.autoClose = wm.shouldAutoClose(url)
1752
1753 // Store requestID for deduplication cleanup
1754 if intent != nil {
1755 requestID = intent.RequestID
1756 }
1757
1758 // Apply window features from JavaScript intent
1759 wm.applyWindowFeatures(newView, intent, true)
1760 }
1761
1762 // Register close handler for popup auto-close
1763 newView.RegisterCloseHandler(func() {
1764 log.Printf("[workspace] Popup requesting close via window.close()")
1765
1766 // Clear the RequestID from deduplicator to allow new popups with same ID
1767 if requestID != "" && wm.paneDeduplicator != nil {
1768 wm.paneDeduplicator.ClearRequestID(requestID)
1769 }
1770
1771 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
1772 time.AfterFunc(200*time.Millisecond, func() {
1773 webkit.IdleAdd(func() bool {
1774 if err := wm.closePane(n); err != nil {
1775 log.Printf("[workspace] Failed to close popup pane: %v", err)
1776 }
1777 return false
1778 })
1779 })
1780 }
1781 })
1782
1783 // URL-based auto-close for OAuth popups
1784 if node != nil && node.isPopup && node.autoClose {
1785 wm.registerOAuthAutoClose(newView, url)
1786 }
1787
1788 wm.ensureGUIInPane(newPane)
1789
1790 if url != "" {
1791 if err := newView.LoadURL(url); err != nil {
1792 log.Printf("[workspace] failed to load popup URL: %v", err)
1793 }
1794 if err := newView.Show(); err != nil {
1795 log.Printf("[workspace] failed to show popup WebView: %v", err)
1796 }
1797 }
1798
1799 log.Printf("[workspace] Created popup pane for URL: %s", url)
1800 return newView
1801}
1802
1803// insertIndependentPane inserts a new independent pane next to the source
1804func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
1805 newPane, err := wm.createPane(webView)
1806 if err != nil {
1807 return err
1808 }
1809 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1810 if direction == "" {
1811 direction = "right"
1812 }
1813 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
1814 return err
1815 }
1816 node := wm.viewToNode[webView]
1817 if node != nil {
1818 node.windowType = webkit.WindowTypeTab
1819 node.isRelated = false
1820 }
1821 if url != "" {
1822 _ = webView.LoadURL(url)
1823 }
1824 return nil
1825}
1826
1827// configureRelatedPopup creates a related view and inserts it
1828func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
1829 // Use the WebView that was already created and returned to WebKit
1830 related := webView
1831 newPane, err := wm.createPane(related)
1832 if err != nil {
1833 log.Printf("[workspace] failed to create related popup pane: %v", err)
1834 return
1835 }
1836 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1837 if direction == "" {
1838 direction = "right"
1839 }
1840 if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1841 log.Printf("[workspace] failed to insert related popup pane: %v", err)
1842 return
1843 }
1844 node := wm.viewToNode[related]
1845 if node != nil {
1846 node.windowType = webkit.WindowTypePopup
1847 node.windowFeatures = feat
1848 node.isRelated = true
1849 node.parentPane = sourceNode
1850 node.isPopup = true
1851 // Heuristic + config for auto-close intent
1852 node.autoClose = wm.shouldAutoClose(url)
1853 }
1854 // Pipe into existing auto-close flow only for popups (confirmed by detection)
1855 related.RegisterCloseHandler(func() {
1856 log.Printf("[workspace] Popup requesting close via window.close()")
1857 if n := wm.viewToNode[related]; n != nil && n.isPopup {
1858 time.AfterFunc(200*time.Millisecond, func() {
1859 webkit.IdleAdd(func() bool {
1860 if err := wm.closePane(n); err != nil {
1861 log.Printf("[workspace] Failed to close popup pane: %v", err)
1862 }
1863 return false
1864 })
1865 })
1866 }
1867 })
1868
1869 // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
1870 if node != nil && node.isPopup && node.autoClose {
1871 wm.registerOAuthAutoClose(related, url)
1872 }
1873 if url != "" {
1874 _ = related.LoadURL(url)
1875 }
1876}
1877
1878// shouldAutoClose checks simple OAuth-like URL patterns and config flag
1879func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
1880 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
1881
1882 if wm == nil || wm.app == nil || wm.app.config == nil {
1883 log.Printf("[workspace] shouldAutoClose: missing config, returning true")
1884 return true
1885 }
1886 if !wm.app.config.Workspace.Popups.OAuthAutoClose {
1887 log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
1888 return false
1889 }
1890
1891 u := strings.ToLower(url)
1892 log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
1893
1894 // RFC 6749 compliant OAuth 2.0 URL patterns
1895 oauthPatterns := []string{
1896 // Standard OAuth endpoints
1897 "oauth", "authorize", "authorization",
1898 // Standard callback/redirect patterns
1899 "callback", "redirect", "auth/callback",
1900 // OpenID Connect patterns
1901 "oidc", "openid",
1902 // Common OAuth parameter indicators
1903 "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
1904 // Standard OAuth response parameters
1905 "code=", "access_token=", "id_token=", "token_type=",
1906 // Error response parameters
1907 "error=", "error_description=",
1908 }
1909
1910 // Check for OAuth patterns in URL
1911 for _, pattern := range oauthPatterns {
1912 if strings.Contains(u, pattern) {
1913 log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
1914 return true
1915 }
1916 }
1917
1918 log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
1919 return false
1920}
1921
1922// isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close
1923
1924// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
1925func (wm *WorkspaceManager) RunOnUI(fn func()) {
1926 if fn != nil {
1927 fn()
1928 }
1929}
1930
1931// handleDetectedWindowType handles window type detection from smart detection path
1932func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
1933 if wm.viewToNode[webView] != nil {
1934 return // Already placed
1935 }
1936
1937 log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
1938
1939 switch windowType {
1940 case webkit.WindowTypeTab:
1941 // For tabs, create a NEW independent WebView (can't use the related one)
1942 webkitCfg, err := wm.app.buildWebkitConfig()
1943 if err != nil {
1944 log.Printf("[workspace] failed to build webkit config for tab: %v", err)
1945 return
1946 }
1947 webkitCfg.CreateWindow = false
1948
1949 // Create independent WebView like handleIntentAsTab does
1950 independentView, err := webkit.NewWebView(webkitCfg)
1951 if err != nil {
1952 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
1953 return
1954 }
1955
1956 // The related webView was just for detection - we don't use it for tabs
1957 // Insert the new independent view as a tab
1958 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
1959 log.Printf("[workspace] Failed to insert independent pane: %v", err)
1960 }
1961
1962 case webkit.WindowTypePopup:
1963 // For popups, use the related WebView we already created
1964 wm.configureRelatedPopup(sourceNode, webView, url, features)
1965 default:
1966 // Fallback to popup behavior
1967 wm.configureRelatedPopup(sourceNode, webView, url, features)
1968 }
1969}
1970
1971// insertPopupPane inserts a pre-created popup pane into the workspace
1972func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
1973 if target == nil || !target.isLeaf || target.pane == nil {
1974 return errors.New("insert target must be a leaf pane")
1975 }
1976
1977 if newPane == nil || newPane.webView == nil {
1978 return errors.New("new pane missing webview")
1979 }
1980
1981 if handler := newPane.MessageHandler(); handler != nil {
1982 handler.SetWorkspaceObserver(wm)
1983 }
1984
1985 newContainer := newPane.webView.RootWidget()
1986 if newContainer == 0 {
1987 return errors.New("new pane missing container")
1988 }
1989
1990 webkit.WidgetSetHExpand(newContainer, true)
1991 webkit.WidgetSetVExpand(newContainer, true)
1992 webkit.WidgetRealizeInContainer(newContainer)
1993
1994 // Also realize the WebView widget itself for proper popup rendering
1995 webViewWidget := newPane.webView.Widget()
1996 if webViewWidget != 0 {
1997 webkit.WidgetRealizeInContainer(webViewWidget)
1998 }
1999
2000 existingContainer := target.container
2001 if existingContainer == 0 {
2002 return errors.New("existing pane missing container")
2003 }
2004
2005 orientation, existingFirst := mapDirection(direction)
2006 log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
2007
2008 paned := webkit.NewPaned(orientation)
2009 if paned == 0 {
2010 return errors.New("failed to create GtkPaned")
2011 }
2012 webkit.WidgetSetHExpand(paned, true)
2013 webkit.WidgetSetVExpand(paned, true)
2014 webkit.PanedSetResizeStart(paned, true)
2015 webkit.PanedSetResizeEnd(paned, true)
2016
2017 newLeaf := &paneNode{
2018 pane: newPane,
2019 container: newContainer,
2020 isLeaf: true,
2021 }
2022 split := &paneNode{
2023 parent: target.parent,
2024 container: paned,
2025 orientation: orientation,
2026 isLeaf: false,
2027 }
2028
2029 parent := split.parent
2030
2031 // Detach existing container from its current GTK parent before inserting into new paned.
2032 if parent == nil {
2033 // Target is the root - remove it from the window
2034 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
2035 if wm.window != nil {
2036 wm.window.SetChild(0)
2037 }
2038 } else if parent.container != 0 {
2039 // Target has a parent paned - unparent it from there
2040 log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
2041 if parent.left == target {
2042 webkit.PanedSetStartChild(parent.container, 0)
2043 } else if parent.right == target {
2044 webkit.PanedSetEndChild(parent.container, 0)
2045 }
2046 webkit.WidgetQueueAllocate(parent.container)
2047 }
2048
2049 // Add both containers to the new paned
2050 if existingFirst {
2051 split.left = target
2052 split.right = newLeaf
2053 webkit.PanedSetStartChild(paned, existingContainer)
2054 webkit.PanedSetEndChild(paned, newContainer)
2055 log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
2056 } else {
2057 split.left = newLeaf
2058 split.right = target
2059 webkit.PanedSetStartChild(paned, newContainer)
2060 webkit.PanedSetEndChild(paned, existingContainer)
2061 log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
2062 }
2063
2064 target.parent = split
2065 newLeaf.parent = split
2066
2067 if parent == nil {
2068 wm.root = split
2069 if wm.window != nil {
2070 wm.window.SetChild(paned)
2071 webkit.WidgetQueueAllocate(paned)
2072 log.Printf("[workspace] paned set as window child: paned=%#x", paned)
2073 }
2074 } else {
2075 if parent.left == target {
2076 parent.left = split
2077 webkit.PanedSetStartChild(parent.container, paned)
2078 } else if parent.right == target {
2079 parent.right = split
2080 webkit.PanedSetEndChild(parent.container, paned)
2081 }
2082 webkit.WidgetQueueAllocate(parent.container)
2083 webkit.WidgetQueueAllocate(paned)
2084 log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
2085 }
2086
2087 webkit.WidgetShow(paned)
2088
2089 wm.viewToNode[newPane.webView] = newLeaf
2090 wm.ensureHover(newLeaf)
2091 wm.ensureHover(target)
2092 wm.app.panes = append(wm.app.panes, newPane)
2093 if newPane.zoomController != nil {
2094 newPane.zoomController.ApplyInitialZoom()
2095 }
2096
2097 // Update CSS classes for all panes now that we have multiple panes
2098 wm.ensurePaneBaseClasses()
2099
2100 webkit.IdleAdd(func() bool {
2101 if newContainer != 0 {
2102 webkit.WidgetShow(newContainer)
2103 }
2104 wm.focusNode(newLeaf)
2105 return false
2106 })
2107
2108 return nil
2109}
2110
2111// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
2112// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
2113// This prevents duplicate GUI injection that was causing duplicate log messages
2114func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
2115 if pane == nil {
2116 return
2117 }
2118
2119 // GUI is already injected globally via WebKit's enableUserContentManager
2120 // Just mark the pane as having GUI to prevent unnecessary calls
2121 if !pane.HasGUI() {
2122 pane.SetHasGUI(true)
2123 pane.SetGUIComponent("manager", true)
2124 pane.SetGUIComponent("omnibox", true)
2125 }
2126}
2127
2128func mapDirection(direction string) (webkit.Orientation, bool) {
2129 switch direction {
2130 case "left":
2131 return webkit.OrientationHorizontal, false
2132 case "up":
2133 return webkit.OrientationVertical, false
2134 case "down":
2135 return webkit.OrientationVertical, true
2136 default:
2137 return webkit.OrientationHorizontal, true
2138 }
2139}