1// workspace_manager.go - Core workspace management and coordination
2package browser
3
4import (
5 "errors"
6 "fmt"
7 "log"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "github.com/bnema/dumber/internal/app/messaging"
14 "github.com/bnema/dumber/pkg/webkit"
15 "github.com/diamondburned/gotk4/pkg/glib/v2"
16 "github.com/diamondburned/gotk4/pkg/gtk/v4"
17)
18
19// WorkspaceManager coordinates Zellij-style pane operations.
20type WorkspaceManager struct {
21 app *BrowserApp
22 window *webkit.Window
23 root *paneNode
24 mainPane *paneNode
25 viewToNode map[*webkit.WebView]*paneNode
26 lastSplitMsg map[*webkit.WebView]time.Time
27 lastExitMsg map[*webkit.WebView]time.Time
28 paneModeActive bool
29 splitting int32 // atomic: 0=false, 1=true
30 cssInitialized bool
31 createWebViewFn func() (*webkit.WebView, error)
32 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
33
34 // Coordination fields for preventing duplicate events
35 paneModeSource *webkit.WebView // Which webview initiated pane mode
36 paneModeActivePane *paneNode // Which pane node has pane mode active
37 paneModeContainer gtk.Widgetter // The container widget that has margin applied
38 paneMutex sync.Mutex // Protects pane mode state
39
40 // Focus throttling fields to prevent infinite loops
41 lastFocusChange time.Time // When focus was last changed
42 focusThrottleMutex sync.Mutex // Protects focus throttling state
43
44 // Stack operation timing to prevent focus conflicts
45 lastStackOperation time.Time // When a stack operation was last performed
46
47 // NEW: Pane creation deduplicator
48 // Popups waiting for ready-to-show lifecycle callback
49 pendingPopups map[uint64]*pendingPopup
50
51 // Specialized managers for different pane operations
52 stackedPaneManager *StackedPaneManager
53 focusStateMachine *FocusStateMachine
54
55 // Focus debouncing to prevent rapid oscillation from any source
56 lastFocusTime time.Time
57 lastFocusTarget *paneNode
58 focusDebounce time.Duration
59
60 // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env)
61 treeValidator *TreeValidator // Debug-only tree validation
62 treeRebalancer *TreeRebalancer // Rebalance after close operations
63 geometryValidator *GeometryValidator // Validate split constraints
64 stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle
65
66 // Debug instrumentation helpers
67 debugLevel DebugLevel
68 debugPaneClose bool
69 diagnostics *WorkspaceDiagnostics
70
71 // Cleanup tracking
72 cleanupCounter uint
73 pendingIdle map[uintptr][]*paneNode
74}
75
76// resolveWindow ensures the workspace has a reference to the GTK window.
77func (wm *WorkspaceManager) resolveWindow() *webkit.Window {
78 if wm == nil {
79 return nil
80 }
81
82 if wm.window != nil {
83 return wm.window
84 }
85
86 if wm.app != nil {
87 if wm.app.tabManager != nil {
88 if win := wm.app.tabManager.GetWindow(); win != nil {
89 wm.window = win
90 return win
91 }
92 }
93 if wm.app.webView != nil {
94 if win := wm.app.webView.Window(); win != nil {
95 wm.window = win
96 return win
97 }
98 }
99 }
100
101 return nil
102}
103
104// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
105
106// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
107func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
108 debugLevel := getDebugLevel()
109
110 manager := &WorkspaceManager{
111 app: app,
112 window: rootPane.webView.Window(),
113 viewToNode: make(map[*webkit.WebView]*paneNode),
114 lastSplitMsg: make(map[*webkit.WebView]time.Time),
115 lastExitMsg: make(map[*webkit.WebView]time.Time),
116 pendingPopups: make(map[uint64]*pendingPopup),
117 focusDebounce: 150 * time.Millisecond,
118 debugLevel: debugLevel,
119 }
120
121 // Initialize validation components (opt-in based on debug level)
122 if debugLevel >= DebugBasic {
123 manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
124 manager.geometryValidator = NewGeometryValidator()
125 manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
126 } else {
127 // Production mode: disable validators
128 manager.treeValidator = NewTreeValidator(false, false)
129 manager.geometryValidator = NewGeometryValidator()
130 }
131
132 // Initialize tree rebalancer (always needed for proper close operations)
133 manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
134
135 // Initialize stack lifecycle manager
136 manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
137
138 // Initialize existing specialized managers (now enhanced with bulletproof components)
139 manager.stackedPaneManager = NewStackedPaneManager(manager)
140 manager.focusStateMachine = NewFocusStateMachine(manager)
141 manager.createWebViewFn = func() (*webkit.WebView, error) {
142 if manager.app == nil {
143 return nil, errors.New("workspace manager missing app reference")
144 }
145 cfg, err := manager.app.buildWebkitConfig()
146 if err != nil {
147 return nil, err
148 }
149 // CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
150 // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
151 // prevents GTK "widget already has parent" errors when adding to paned containers.
152 cfg.CreateWindow = false
153 return webkit.NewWebView(cfg)
154 }
155 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
156 if manager.app == nil {
157 return nil, errors.New("workspace manager missing app reference")
158 }
159 return manager.app.createPaneForView(view)
160 }
161 manager.ensureWorkspaceStyles()
162
163 rootContainer := rootPane.webView.RootWidget()
164 root := &paneNode{
165 pane: rootPane,
166 isLeaf: true,
167 }
168 // Initialize widgets properly
169 manager.initializePaneWidgets(root, rootContainer)
170
171 manager.root = root
172 manager.mainPane = root
173 manager.viewToNode[rootPane.webView] = root
174 manager.setupPopupHandling(rootPane.webView, root)
175 manager.ensureHover(root)
176
177 if handler := rootPane.MessageHandler(); handler != nil {
178 handler.SetWorkspaceObserver(manager)
179 }
180
181 // CRITICAL: Register navigation handler (including pane mode) for the root pane
182 // This must happen here because when creating the first tab, app.workspace is nil
183 // in createPaneForView, so it doesn't register the handler
184 manager.RegisterNavigationHandler(rootPane.webView)
185
186 // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
187 // TabManager sets app.workspace during tab switching (including initial tab creation).
188 // This prevents workspaces from interfering with each other when multiple tabs exist.
189
190 // Configure debug settings from app config
191 if app.config != nil {
192 manager.focusStateMachine.ConfigureDebug(
193 app.config.Debug.EnableFocusDebug,
194 false, // CSS debug removed
195 app.config.Debug.EnableFocusMetrics,
196 )
197
198 manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
199 }
200
201 manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
202
203 // Initialize focus state machine after all setup is complete
204 if err := manager.focusStateMachine.Initialize(); err != nil {
205 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
206 }
207
208 // Ensure hover controllers are attached to all panes for mouse-driven focus changes
209 manager.attachHoverHandlersToAllPanes()
210
211 if manager.debugPaneClose {
212 manager.dumpTreeState("workspace_init")
213 }
214
215 return manager
216}
217
218// shouldDebounce checks if an operation should be debounced based on timing
219func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
220 if last, ok := wm.lastSplitMsg[source]; ok {
221 if elapsed := time.Since(last); elapsed < threshold {
222 log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
223 return true
224 }
225 }
226 wm.lastSplitMsg[source] = time.Now()
227 return false
228}
229
230// withSplitLock executes an operation with atomic splitting lock protection
231func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
232 if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
233 log.Printf("[workspace] %s ignored: operation in progress", operation)
234 return fmt.Errorf("operation in progress")
235 }
236
237 defer func() {
238 atomic.StoreInt32(&wm.splitting, 0)
239 if r := recover(); r != nil {
240 log.Printf("[workspace] %s panicked: %v", operation, r)
241 panic(r)
242 }
243 }()
244
245 return fn()
246}
247
248// cleanupPopupStorage removes localStorage entries for a closed popup
249func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
250 if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
251 return
252 }
253
254 requestID := targetNode.requestID
255 parentWebViewID := parentNode.pane.WebView().ID()
256
257 script := fmt.Sprintf(`
258 try {
259 localStorage.removeItem('popup_mapping_%d');
260 const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
261 'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
262 'oauth_callback_%s'];
263 keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
264 console.log('[workspace] Cleaned popup localStorage');
265 } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
266 `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
267
268 if err := parentNode.pane.WebView().InjectScript(script); err != nil {
269 log.Printf("[workspace] localStorage cleanup failed: %v", err)
270 } else {
271 log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
272 }
273}
274
275// OnWorkspaceMessage implements messaging.WorkspaceObserver.
276func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
277 node := wm.viewToNode[source]
278
279 if msg.WebViewID != "" {
280 if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
281 if explicitNode.pane != nil && explicitNode.pane.webView != nil {
282 node = explicitNode
283 source = explicitNode.pane.webView
284 }
285 }
286 }
287
288 if node == nil {
289 log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
290 return
291 }
292
293 switch event := strings.ToLower(msg.Event); event {
294 case "pane-split":
295 wm.SetActivePane(node, SourceProgrammatic)
296 direction := strings.ToLower(msg.Direction)
297 if direction == "" {
298 direction = DirectionRight
299 }
300
301 if wm.shouldDebounce(source, 200*time.Millisecond) {
302 return
303 }
304
305 err := wm.withSplitLock("split", func() error {
306 newNode, err := wm.SplitPane(node, direction)
307 if err != nil {
308 if glib.MainContextDefault().IsOwner() {
309 glib.MainContextDefault().Iteration(false)
310 }
311 return err
312 }
313 wm.clonePaneState(node, newNode)
314 return nil
315 })
316
317 if err != nil {
318 log.Printf("[workspace] split failed: %v", err)
319 }
320
321 case "pane-stack":
322 wm.SetActivePane(node, SourceProgrammatic)
323
324 if wm.shouldDebounce(source, 200*time.Millisecond) {
325 return
326 }
327
328 err := wm.withSplitLock("stack", func() error {
329 newNode, err := wm.stackedPaneManager.StackPane(node)
330 if err != nil {
331 if glib.MainContextDefault().IsOwner() {
332 glib.MainContextDefault().Iteration(false)
333 }
334 return err
335 }
336 wm.clonePaneState(node, newNode)
337 return nil
338 })
339
340 if err != nil {
341 log.Printf("[workspace] stack failed: %v", err)
342 }
343
344 case "close-popup":
345 if msg.WebViewID == "" {
346 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
347 return
348 }
349
350 // Find popup pane by webview ID
351 var targetNode *paneNode
352 for webView, node := range wm.viewToNode {
353 if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
354 targetNode = node
355 break
356 }
357 }
358
359 if targetNode == nil {
360 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
361 return
362 }
363
364 if !targetNode.isPopup {
365 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
366 return
367 }
368
369 log.Printf("[workspace] Closing popup due to %s", msg.Reason)
370
371 // Remove from parent's active popup list
372 if targetNode.parentPane != nil {
373 parentNode := targetNode.parentPane
374 for i, childID := range parentNode.activePopupChildren {
375 if childID == msg.WebViewID {
376 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
377 log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
378 break
379 }
380 }
381
382 wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
383 }
384
385 if err := wm.ClosePane(targetNode); err != nil {
386 log.Printf("[workspace] Failed to close popup: %v", err)
387 }
388
389 default:
390 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
391 }
392}
393
394func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
395 if wm == nil || id == "" {
396 return nil
397 }
398
399 for webView, node := range wm.viewToNode {
400 if webView != nil && webView.IDString() == id {
401 return node
402 }
403 }
404 return nil
405}
406
407// GetActiveNode returns the currently active pane node
408func (wm *WorkspaceManager) GetActiveNode() *paneNode {
409 if wm == nil || wm.focusStateMachine == nil {
410 return nil
411 }
412 return wm.focusStateMachine.GetActivePane()
413}
414
415// SetActivePane requests focus change through the focus state machine with debouncing
416func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
417 now := time.Now()
418
419 // Check if we should debounce this focus request (except for urgent system events)
420 if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
421 // Too soon and same target - ignore this request to prevent rapid oscillation
422 return
423 }
424
425 // Update tracking and proceed with focus change
426 wm.lastFocusTime = now
427 wm.lastFocusTarget = node
428
429 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
430 log.Printf("[workspace] Focus request failed: %v", err)
431 }
432}
433
434// GetNodeForWebView returns the pane node associated with a WebView
435func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
436 return wm.viewToNode[webView]
437}
438
439// RegisterNavigationHandler sets up navigation handling for a webview
440func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
441 if webView == nil {
442 return
443 }
444
445 // Register workspace navigation callback for alt+arrow pane navigation
446 webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
447 if wm == nil {
448 return false
449 }
450 return wm.FocusNeighbor(direction)
451 })
452
453 // Register pane mode callbacks for Ctrl+P and pane mode action keys
454 webView.RegisterPaneModeHandler(func(action string) bool {
455 if wm == nil {
456 return false
457 }
458
459 if action == "enter" {
460 // CRITICAL FIX: Use app.workspace instead of closure's wm
461 // This ensures Ctrl+P always targets the ACTIVE tab's workspace
462 if wm.app != nil && wm.app.workspace != nil {
463 wm.app.workspace.EnterPaneMode()
464 return true
465 }
466 return false
467 }
468
469 if action == "exit" {
470 // Check if pane mode is active
471 wm.paneMutex.Lock()
472 wasActive := wm.paneModeActive
473 wm.paneMutex.Unlock()
474
475 if wasActive {
476 wm.ExitPaneMode("escape")
477 return true // Mode was active, we handled it
478 }
479 return false // No mode active, pass through to DOM
480 }
481
482 // For other actions, use active workspace (same pattern as "enter")
483 // This ensures action keys work on the correct tab
484 if wm.app != nil && wm.app.workspace != nil {
485 // Check if pane mode is active on the active workspace
486 activeWs := wm.app.workspace
487 activeWs.paneMutex.Lock()
488 isActive := activeWs.paneModeActive
489 activeWs.paneMutex.Unlock()
490
491 if !isActive {
492 return false // Not in pane mode, allow normal behavior
493 }
494
495 // Pane mode is active, handle the action on active workspace
496 activeWs.HandlePaneAction(action)
497 return true
498 }
499 return false
500 }, func() bool {
501 // Check if pane mode is currently active
502 wm.paneMutex.Lock()
503 defer wm.paneMutex.Unlock()
504 return wm.paneModeActive
505 })
506
507 // Register middle-click link handler to open links in new panes
508 webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
509 if wm == nil {
510 return false
511 }
512
513 log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
514
515 // Get the node for this webview
516 node := wm.GetNodeForWebView(webView)
517 if node == nil {
518 log.Printf("[workspace] Cannot find node for middle-clicked link")
519 return false
520 }
521
522 // Get behavior from BlankTargetBehavior config
523 behavior := "stacked" // Default
524 if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
525 behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
526 }
527
528 var newNode *paneNode
529 var err error
530
531 switch behavior {
532 case "stacked":
533 // Stack the current pane - this also creates a new pane in the stack
534 log.Printf("[workspace] Using stacked behavior for gesture link")
535 newNode, err = wm.stackedPaneManager.StackPane(node)
536 if err != nil {
537 log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
538 return false
539 }
540 // newNode is the new pane that was added to the stack
541 log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
542
543 case "split":
544 // Regular split behavior
545 log.Printf("[workspace] Using split behavior for gesture link")
546 direction := "right"
547 if wm.app.config.Workspace.Popups.Placement != "" {
548 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
549 }
550 newNode, err = wm.SplitPane(node, direction)
551
552 case "tabbed":
553 // Tabbed not yet implemented, fall back to split
554 log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
555 direction := "right"
556 if wm.app.config.Workspace.Popups.Placement != "" {
557 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
558 }
559 newNode, err = wm.SplitPane(node, direction)
560
561 default:
562 // Unknown behavior, fall back to split
563 log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
564 direction := "right"
565 if wm.app.config.Workspace.Popups.Placement != "" {
566 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
567 }
568 newNode, err = wm.SplitPane(node, direction)
569 }
570
571 if err != nil {
572 log.Printf("[workspace] Failed to handle gesture link: %v", err)
573 return false
574 }
575
576 // Navigate the new pane to the URL
577 if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
578 if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
579 log.Printf("[workspace] Failed to load URL in new pane: %v", err)
580 return false
581 }
582
583 // Set the new pane as active
584 wm.SetActivePane(newNode, SourceProgrammatic)
585 }
586
587 return true // Indicate we handled the click
588 })
589
590 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
591}
592
593// GetAllPanes returns all BrowserPanes in this workspace.
594// This is used by TabManager to update app-level panes reference.
595func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane {
596 panes := make([]*BrowserPane, 0, len(wm.viewToNode))
597 for _, node := range wm.viewToNode {
598 if node.pane != nil {
599 panes = append(panes, node.pane)
600 }
601 }
602 return panes
603}
604
605// GetActivePane returns the currently active BrowserPane in this workspace.
606// This is used by TabManager to update app-level activePane reference.
607func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
608 node := wm.GetActiveNode()
609 if node != nil && node.pane != nil {
610 return node.pane
611 }
612 return nil
613}
614
615// RestoreFocus restores focus to the active pane in this workspace.
616// Called when switching to a tab to ensure the correct pane has focus.
617func (wm *WorkspaceManager) RestoreFocus() {
618 log.Printf("[workspace] RestoreFocus: workspace %p", wm)
619
620 // Get the active pane and explicitly grab GTK focus
621 activeNode := wm.GetActiveNode()
622 if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
623 // Explicitly grab GTK focus to this WebView
624 // This ensures keyboard events go to the active tab's WebView
625 // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
626 webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
627 log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
628
629 // Also request focus via FSM
630 if wm.focusStateMachine != nil {
631 wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
632 }
633 }
634}
635
636// IsInActiveTab returns whether this workspace is the app's current active workspace.
637// This is checked dynamically by comparing with app.workspace (updated during tab switching).
638func (wm *WorkspaceManager) IsInActiveTab() bool {
639 if wm.app == nil {
640 return false
641 }
642 // The active workspace is the one currently assigned to app.workspace
643 isActive := wm.app.workspace == wm
644 log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
645 return isActive
646}
647
648// ClearFocus removes GTK focus from all panes in this workspace.
649// This prevents inactive tab's WebViews from catching keyboard events.
650func (wm *WorkspaceManager) ClearFocus() {
651 log.Printf("[workspace] ClearFocus: workspace %p", wm)
652 // No state to clear - IsInActiveTab() checks dynamically
653}
654
655// GetRootWidget returns the root GTK widget for this workspace.
656// This is the container that should be added to the tab's content area.
657func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
658 if wm.root != nil && wm.root.container != nil {
659 return wm.root.container
660 }
661 return nil
662}
663
664// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
665// The tab system is always present (even with a single tab), so this always uses ContentArea.
666func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
667 if container == nil {
668 return
669 }
670
671 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
672 log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
673 return
674 }
675
676 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
677 if !ok || contentBox == nil {
678 log.Printf("[workspace] ERROR: ContentArea is not a Box")
679 return
680 }
681
682 contentBox.Append(container)
683 webkit.WidgetSetVisible(container, true)
684 log.Printf("[workspace] Attached root container %p to ContentArea", container)
685}
686
687// clearRootContainer removes the current workspace root from TabManager.ContentArea.
688// The tab system is always present (even with a single tab), so this always uses ContentArea.
689func (wm *WorkspaceManager) clearRootContainer() {
690 if wm.root == nil || wm.root.container == nil {
691 return
692 }
693
694 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
695 log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
696 return
697 }
698
699 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
700 if !ok || contentBox == nil {
701 log.Printf("[workspace] ERROR: ContentArea is not a Box")
702 return
703 }
704
705 contentBox.Remove(wm.root.container)
706 log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
707}