v0.10.0 0f4e9eb

workspace_manager.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close