detached 963bbe1

chore: update package-lock and preserve deprecated workspace_manager for reference

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

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