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.ensureHover(root)
175
176 if handler := rootPane.MessageHandler(); handler != nil {
177 handler.SetWorkspaceObserver(manager)
178 }
179
180 // CRITICAL: Register navigation handler (including pane mode) for the root pane
181 // This must happen here because when creating the first tab, app.workspace is nil
182 // in createPaneForView, so it doesn't register the handler
183 manager.RegisterNavigationHandler(rootPane.webView)
184
185 // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
186 // TabManager sets app.workspace during tab switching (including initial tab creation).
187 // This prevents workspaces from interfering with each other when multiple tabs exist.
188
189 // Configure debug settings from app config
190 if app.config != nil {
191 manager.focusStateMachine.ConfigureDebug(
192 app.config.Debug.EnableFocusDebug,
193 false, // CSS debug removed
194 app.config.Debug.EnableFocusMetrics,
195 )
196
197 manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
198 }
199
200 manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
201
202 // Initialize focus state machine after all setup is complete
203 if err := manager.focusStateMachine.Initialize(); err != nil {
204 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
205 }
206
207 // Ensure hover controllers are attached to all panes for mouse-driven focus changes
208 manager.attachHoverHandlersToAllPanes()
209
210 if manager.debugPaneClose {
211 manager.dumpTreeState("workspace_init")
212 }
213
214 return manager
215}
216
217// shouldDebounce checks if an operation should be debounced based on timing
218func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
219 if last, ok := wm.lastSplitMsg[source]; ok {
220 if elapsed := time.Since(last); elapsed < threshold {
221 log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
222 return true
223 }
224 }
225 wm.lastSplitMsg[source] = time.Now()
226 return false
227}
228
229// withSplitLock executes an operation with atomic splitting lock protection
230func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
231 if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
232 log.Printf("[workspace] %s ignored: operation in progress", operation)
233 return fmt.Errorf("operation in progress")
234 }
235
236 defer func() {
237 atomic.StoreInt32(&wm.splitting, 0)
238 if r := recover(); r != nil {
239 log.Printf("[workspace] %s panicked: %v", operation, r)
240 panic(r)
241 }
242 }()
243
244 return fn()
245}
246
247// cleanupPopupStorage removes localStorage entries for a closed popup
248func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
249 if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
250 return
251 }
252
253 requestID := targetNode.requestID
254 parentWebViewID := parentNode.pane.WebView().ID()
255
256 script := fmt.Sprintf(`
257 try {
258 localStorage.removeItem('popup_mapping_%d');
259 const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
260 'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
261 'oauth_callback_%s'];
262 keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
263 console.log('[workspace] Cleaned popup localStorage');
264 } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
265 `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
266
267 if err := parentNode.pane.WebView().InjectScript(script); err != nil {
268 log.Printf("[workspace] localStorage cleanup failed: %v", err)
269 } else {
270 log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
271 }
272}
273
274// OnWorkspaceMessage implements messaging.WorkspaceObserver.
275func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
276 node := wm.viewToNode[source]
277
278 if msg.WebViewID != "" {
279 if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
280 if explicitNode.pane != nil && explicitNode.pane.webView != nil {
281 node = explicitNode
282 source = explicitNode.pane.webView
283 }
284 }
285 }
286
287 if node == nil {
288 log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
289 return
290 }
291
292 switch event := strings.ToLower(msg.Event); event {
293 case "pane-split":
294 wm.SetActivePane(node, SourceProgrammatic)
295 direction := strings.ToLower(msg.Direction)
296 if direction == "" {
297 direction = DirectionRight
298 }
299
300 if wm.shouldDebounce(source, 200*time.Millisecond) {
301 return
302 }
303
304 err := wm.withSplitLock("split", func() error {
305 newNode, err := wm.SplitPane(node, direction)
306 if err != nil {
307 if glib.MainContextDefault().IsOwner() {
308 glib.MainContextDefault().Iteration(false)
309 }
310 return err
311 }
312 wm.clonePaneState(node, newNode)
313 return nil
314 })
315
316 if err != nil {
317 log.Printf("[workspace] split failed: %v", err)
318 }
319
320 case "pane-stack":
321 wm.SetActivePane(node, SourceProgrammatic)
322
323 if wm.shouldDebounce(source, 200*time.Millisecond) {
324 return
325 }
326
327 err := wm.withSplitLock("stack", func() error {
328 newNode, err := wm.stackedPaneManager.StackPane(node)
329 if err != nil {
330 if glib.MainContextDefault().IsOwner() {
331 glib.MainContextDefault().Iteration(false)
332 }
333 return err
334 }
335 wm.clonePaneState(node, newNode)
336 return nil
337 })
338
339 if err != nil {
340 log.Printf("[workspace] stack failed: %v", err)
341 }
342
343 case "close-popup":
344 if msg.WebViewID == "" {
345 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
346 return
347 }
348
349 // Find popup pane by webview ID
350 var targetNode *paneNode
351 for webView, node := range wm.viewToNode {
352 if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
353 targetNode = node
354 break
355 }
356 }
357
358 if targetNode == nil {
359 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
360 return
361 }
362
363 if !targetNode.isPopup {
364 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
365 return
366 }
367
368 log.Printf("[workspace] Closing popup due to %s", msg.Reason)
369
370 // Remove from parent's active popup list
371 if targetNode.parentPane != nil {
372 parentNode := targetNode.parentPane
373 for i, childID := range parentNode.activePopupChildren {
374 if childID == msg.WebViewID {
375 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
376 log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
377 break
378 }
379 }
380
381 wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
382 }
383
384 if err := wm.ClosePane(targetNode); err != nil {
385 log.Printf("[workspace] Failed to close popup: %v", err)
386 }
387
388 default:
389 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
390 }
391}
392
393func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
394 if wm == nil || id == "" {
395 return nil
396 }
397
398 for webView, node := range wm.viewToNode {
399 if webView != nil && webView.IDString() == id {
400 return node
401 }
402 }
403 return nil
404}
405
406// GetActiveNode returns the currently active pane node
407func (wm *WorkspaceManager) GetActiveNode() *paneNode {
408 if wm == nil || wm.focusStateMachine == nil {
409 return nil
410 }
411 return wm.focusStateMachine.GetActivePane()
412}
413
414// SetActivePane requests focus change through the focus state machine with debouncing
415func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
416 now := time.Now()
417
418 // Check if we should debounce this focus request (except for urgent system events)
419 if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
420 // Too soon and same target - ignore this request to prevent rapid oscillation
421 return
422 }
423
424 // Update tracking and proceed with focus change
425 wm.lastFocusTime = now
426 wm.lastFocusTarget = node
427
428 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
429 log.Printf("[workspace] Focus request failed: %v", err)
430 }
431}
432
433// GetNodeForWebView returns the pane node associated with a WebView
434func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
435 return wm.viewToNode[webView]
436}
437
438// RegisterNavigationHandler sets up navigation handling for a webview
439func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
440 if webView == nil {
441 return
442 }
443
444 // Register workspace navigation callback for alt+arrow pane navigation
445 webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
446 if wm == nil {
447 return false
448 }
449 return wm.FocusNeighbor(direction)
450 })
451
452 // Register pane mode callbacks for Ctrl+P and pane mode action keys
453 webView.RegisterPaneModeHandler(func(action string) bool {
454 if wm == nil {
455 return false
456 }
457
458 if action == "enter" {
459 // CRITICAL FIX: Use app.workspace instead of closure's wm
460 // This ensures Ctrl+P always targets the ACTIVE tab's workspace
461 if wm.app != nil && wm.app.workspace != nil {
462 wm.app.workspace.EnterPaneMode()
463 return true
464 }
465 return false
466 }
467
468 if action == "exit" {
469 // Check if pane mode is active
470 wm.paneMutex.Lock()
471 wasActive := wm.paneModeActive
472 wm.paneMutex.Unlock()
473
474 if wasActive {
475 wm.ExitPaneMode("escape")
476 return true // Mode was active, we handled it
477 }
478 return false // No mode active, pass through to DOM
479 }
480
481 // For other actions, use active workspace (same pattern as "enter")
482 // This ensures action keys work on the correct tab
483 if wm.app != nil && wm.app.workspace != nil {
484 // Check if pane mode is active on the active workspace
485 activeWs := wm.app.workspace
486 activeWs.paneMutex.Lock()
487 isActive := activeWs.paneModeActive
488 activeWs.paneMutex.Unlock()
489
490 if !isActive {
491 return false // Not in pane mode, allow normal behavior
492 }
493
494 // Pane mode is active, handle the action on active workspace
495 activeWs.HandlePaneAction(action)
496 return true
497 }
498 return false
499 }, func() bool {
500 // Check if pane mode is currently active
501 wm.paneMutex.Lock()
502 defer wm.paneMutex.Unlock()
503 return wm.paneModeActive
504 })
505
506 // Register middle-click link handler to open links in new panes
507 webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
508 if wm == nil {
509 return false
510 }
511
512 log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
513
514 // Get the node for this webview
515 node := wm.GetNodeForWebView(webView)
516 if node == nil {
517 log.Printf("[workspace] Cannot find node for middle-clicked link")
518 return false
519 }
520
521 // Get behavior from BlankTargetBehavior config
522 behavior := "stacked" // Default
523 if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
524 behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
525 }
526
527 var newNode *paneNode
528 var err error
529
530 switch behavior {
531 case "stacked":
532 // Stack the current pane - this also creates a new pane in the stack
533 log.Printf("[workspace] Using stacked behavior for gesture link")
534 newNode, err = wm.stackedPaneManager.StackPane(node)
535 if err != nil {
536 log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
537 return false
538 }
539 // newNode is the new pane that was added to the stack
540 log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
541
542 case "split":
543 // Regular split behavior
544 log.Printf("[workspace] Using split behavior for gesture link")
545 direction := "right"
546 if wm.app.config.Workspace.Popups.Placement != "" {
547 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
548 }
549 newNode, err = wm.SplitPane(node, direction)
550
551 case "tabbed":
552 // Tabbed not yet implemented, fall back to split
553 log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
554 direction := "right"
555 if wm.app.config.Workspace.Popups.Placement != "" {
556 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
557 }
558 newNode, err = wm.SplitPane(node, direction)
559
560 default:
561 // Unknown behavior, fall back to split
562 log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
563 direction := "right"
564 if wm.app.config.Workspace.Popups.Placement != "" {
565 direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
566 }
567 newNode, err = wm.SplitPane(node, direction)
568 }
569
570 if err != nil {
571 log.Printf("[workspace] Failed to handle gesture link: %v", err)
572 return false
573 }
574
575 // Navigate the new pane to the URL
576 if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
577 if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
578 log.Printf("[workspace] Failed to load URL in new pane: %v", err)
579 return false
580 }
581
582 // Set the new pane as active
583 wm.SetActivePane(newNode, SourceProgrammatic)
584 }
585
586 return true // Indicate we handled the click
587 })
588
589 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
590}
591
592// GetAllPanes returns all BrowserPanes in this workspace.
593// This is used by TabManager to update app-level panes reference.
594func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane {
595 panes := make([]*BrowserPane, 0, len(wm.viewToNode))
596 for _, node := range wm.viewToNode {
597 if node.pane != nil {
598 panes = append(panes, node.pane)
599 }
600 }
601 return panes
602}
603
604// GetActivePane returns the currently active BrowserPane in this workspace.
605// This is used by TabManager to update app-level activePane reference.
606func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
607 node := wm.GetActiveNode()
608 if node != nil && node.pane != nil {
609 return node.pane
610 }
611 return nil
612}
613
614// RestoreFocus restores focus to the active pane in this workspace.
615// Called when switching to a tab to ensure the correct pane has focus.
616func (wm *WorkspaceManager) RestoreFocus() {
617 log.Printf("[workspace] RestoreFocus: workspace %p", wm)
618
619 // Get the active pane and explicitly grab GTK focus
620 activeNode := wm.GetActiveNode()
621 if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
622 // Explicitly grab GTK focus to this WebView
623 // This ensures keyboard events go to the active tab's WebView
624 // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
625 webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
626 log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
627
628 // Also request focus via FSM
629 if wm.focusStateMachine != nil {
630 wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
631 }
632 }
633}
634
635// IsInActiveTab returns whether this workspace is the app's current active workspace.
636// This is checked dynamically by comparing with app.workspace (updated during tab switching).
637func (wm *WorkspaceManager) IsInActiveTab() bool {
638 if wm.app == nil {
639 return false
640 }
641 // The active workspace is the one currently assigned to app.workspace
642 isActive := wm.app.workspace == wm
643 log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
644 return isActive
645}
646
647// ClearFocus removes GTK focus from all panes in this workspace.
648// This prevents inactive tab's WebViews from catching keyboard events.
649func (wm *WorkspaceManager) ClearFocus() {
650 log.Printf("[workspace] ClearFocus: workspace %p", wm)
651 // No state to clear - IsInActiveTab() checks dynamically
652}
653
654// GetRootWidget returns the root GTK widget for this workspace.
655// This is the container that should be added to the tab's content area.
656func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
657 if wm.root != nil && wm.root.container != nil {
658 return wm.root.container
659 }
660 return nil
661}
662
663// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
664// The tab system is always present (even with a single tab), so this always uses ContentArea.
665func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
666 if container == nil {
667 return
668 }
669
670 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
671 log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
672 return
673 }
674
675 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
676 if !ok || contentBox == nil {
677 log.Printf("[workspace] ERROR: ContentArea is not a Box")
678 return
679 }
680
681 contentBox.Append(container)
682 webkit.WidgetSetVisible(container, true)
683 log.Printf("[workspace] Attached root container %p to ContentArea", container)
684}
685
686// clearRootContainer removes the current workspace root from TabManager.ContentArea.
687// The tab system is always present (even with a single tab), so this always uses ContentArea.
688func (wm *WorkspaceManager) clearRootContainer() {
689 if wm.root == nil || wm.root.container == nil {
690 return
691 }
692
693 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
694 log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
695 return
696 }
697
698 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
699 if !ok || contentBox == nil {
700 log.Printf("[workspace] ERROR: ContentArea is not a Box")
701 return
702 }
703
704 contentBox.Remove(wm.root.container)
705 log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
706}