v0.9.0 636c44c

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// 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}

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