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)
16
17// WorkspaceManager coordinates Zellij-style pane operations.
18type WorkspaceManager struct {
19 app *BrowserApp
20 window *webkit.Window
21 root *paneNode
22 mainPane *paneNode
23 viewToNode map[*webkit.WebView]*paneNode
24 lastSplitMsg map[*webkit.WebView]time.Time
25 lastExitMsg map[*webkit.WebView]time.Time
26 paneModeActive bool
27 splitting int32 // atomic: 0=false, 1=true
28 cssInitialized bool
29 createWebViewFn func() (*webkit.WebView, error)
30 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
31
32 // Coordination fields for preventing duplicate events
33 paneModeSource *webkit.WebView // Which webview initiated pane mode
34 lastPaneModeEntry time.Time // When pane mode was last entered
35 paneMutex sync.Mutex // Protects pane mode state
36
37 // Focus throttling fields to prevent infinite loops
38 lastFocusChange time.Time // When focus was last changed
39 focusThrottleMutex sync.Mutex // Protects focus throttling state
40
41 // Stack operation timing to prevent focus conflicts
42 lastStackOperation time.Time // When a stack operation was last performed
43
44 // NEW: Pane creation deduplicator
45 paneDeduplicator *messaging.PaneRequestDeduplicator
46
47 // Specialized managers for different pane operations
48 stackedPaneManager *StackedPaneManager
49 focusStateMachine *FocusStateMachine
50
51 // Focus debouncing to prevent rapid oscillation from any source
52 lastFocusTime time.Time
53 lastFocusTarget *paneNode
54 focusDebounce time.Duration
55
56 // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env)
57 treeValidator *TreeValidator // Debug-only tree validation
58 treeRebalancer *TreeRebalancer // Rebalance after close operations
59 geometryValidator *GeometryValidator // Validate split constraints
60 stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle
61
62 // Debug instrumentation helpers
63 debugLevel DebugLevel
64 debugPaneClose bool
65 diagnostics *WorkspaceDiagnostics
66
67 // Cleanup tracking
68 cleanupCounter uint
69 pendingIdle map[uintptr][]*paneNode
70}
71
72// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
73
74// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
75func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
76 debugLevel := getDebugLevel()
77
78 manager := &WorkspaceManager{
79 app: app,
80 window: rootPane.webView.Window(),
81 viewToNode: make(map[*webkit.WebView]*paneNode),
82 lastSplitMsg: make(map[*webkit.WebView]time.Time),
83 lastExitMsg: make(map[*webkit.WebView]time.Time),
84 paneDeduplicator: messaging.NewPaneRequestDeduplicator(),
85 focusDebounce: 150 * time.Millisecond,
86 debugLevel: debugLevel,
87 }
88
89 // Initialize validation components (opt-in based on debug level)
90 if debugLevel >= DebugBasic {
91 manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
92 manager.geometryValidator = NewGeometryValidator()
93 manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
94 } else {
95 // Production mode: disable validators
96 manager.treeValidator = NewTreeValidator(false, false)
97 manager.geometryValidator = NewGeometryValidator()
98 }
99
100 // Initialize tree rebalancer (always needed for proper close operations)
101 manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
102
103 // Initialize stack lifecycle manager
104 manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
105
106 // Initialize existing specialized managers (now enhanced with bulletproof components)
107 manager.stackedPaneManager = NewStackedPaneManager(manager)
108 manager.focusStateMachine = NewFocusStateMachine(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 // Pane WebViews are embedded inside the main application window, so they do not
118 // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK
119 // ownership simple (Epiphany follows the same pattern) and to prevent premature
120 // swapchain destruction when panes are closed.
121 cfg.CreateWindow = false
122 return webkit.NewWebView(cfg)
123 }
124 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
125 if manager.app == nil {
126 return nil, errors.New("workspace manager missing app reference")
127 }
128 return manager.app.createPaneForView(view)
129 }
130 manager.ensureWorkspaceStyles()
131
132 rootContainer := rootPane.webView.RootWidget()
133 root := &paneNode{
134 pane: rootPane,
135 isLeaf: true,
136 }
137 // Initialize widgets properly
138 manager.initializePaneWidgets(root, rootContainer)
139
140 manager.root = root
141 manager.mainPane = root
142 manager.viewToNode[rootPane.webView] = root
143 manager.ensureHover(root)
144
145 if handler := rootPane.MessageHandler(); handler != nil {
146 handler.SetWorkspaceObserver(manager)
147 }
148
149 app.workspace = manager
150
151 // Configure debug settings from app config
152 if app.config != nil {
153 manager.focusStateMachine.ConfigureDebug(
154 app.config.Debug.EnableFocusDebug,
155 false, // CSS debug removed
156 app.config.Debug.EnableFocusMetrics,
157 )
158
159 manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
160 }
161
162 manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
163
164 // Initialize focus state machine after all setup is complete
165 if err := manager.focusStateMachine.Initialize(); err != nil {
166 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
167 }
168
169 if manager.debugPaneClose {
170 manager.dumpTreeState("workspace_init")
171 }
172
173 return manager
174}
175
176// OnWorkspaceMessage implements messaging.WorkspaceObserver.
177func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
178 node := wm.viewToNode[source]
179 if node == nil {
180 log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
181 return
182 }
183
184 switch event := strings.ToLower(msg.Event); event {
185 case "pane-mode-entered":
186 // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
187 wm.paneMutex.Lock()
188 defer wm.paneMutex.Unlock()
189
190 // Check if pane mode was already entered recently (within 200ms debounce window)
191 if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
192 log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
193 time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
194 return
195 }
196
197 // Check if pane mode is already active from a different source
198 if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
199 log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
200 wm.paneModeSource, source)
201 return
202 }
203
204 log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
205 wm.paneModeActive = true
206 wm.paneModeSource = source
207 wm.lastPaneModeEntry = time.Now()
208 wm.SetActivePane(node, SourceProgrammatic)
209 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
210 // Debounce pane-mode-exited events to prevent duplicate focus calls
211 if event == "pane-mode-exited" {
212 if last, ok := wm.lastExitMsg[source]; ok {
213 if time.Since(last) < 100*time.Millisecond {
214 log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
215 return
216 }
217 }
218 wm.lastExitMsg[source] = time.Now()
219 }
220
221 wm.paneMutex.Lock()
222 wm.paneModeActive = false
223 wm.paneModeSource = nil
224 wm.paneMutex.Unlock()
225
226 wm.focusAfterPaneMode(node)
227 case "pane-close":
228 if !wm.paneModeActive {
229 log.Printf("[workspace] close requested outside pane mode; ignoring")
230 break
231 }
232
233 wm.paneMutex.Lock()
234 wm.paneModeActive = false
235 wm.paneModeSource = nil
236 wm.paneMutex.Unlock()
237
238 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
239 wm.closeCurrentPane()
240 case "pane-split":
241 wm.SetActivePane(node, SourceProgrammatic)
242 direction := strings.ToLower(msg.Direction)
243 if direction == "" {
244 direction = "right"
245 }
246 if last, ok := wm.lastSplitMsg[source]; ok {
247 if time.Since(last) < 200*time.Millisecond {
248 log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
249 return
250 }
251 }
252
253 // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions
254 if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
255 log.Printf("[workspace] split ignored: operation already in progress")
256 return
257 }
258
259 // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
260 defer func() {
261 atomic.StoreInt32(&wm.splitting, 0)
262 if r := recover(); r != nil {
263 log.Printf("[workspace] split operation panicked, flag cleared: %v", r)
264 panic(r) // Re-panic to maintain error handling
265 }
266 }()
267
268 wm.lastSplitMsg[source] = time.Now()
269
270 newNode, err := wm.SplitPane(node, direction)
271 if err != nil {
272 log.Printf("[workspace] split failed: %v", err)
273 // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations
274 if webkit.IsMainThread() {
275 webkit.IterateMainLoop()
276 }
277 return
278 }
279 wm.clonePaneState(node, newNode)
280 case "pane-stack":
281 wm.SetActivePane(node, SourceProgrammatic)
282 if last, ok := wm.lastSplitMsg[source]; ok {
283 if time.Since(last) < 200*time.Millisecond {
284 log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
285 return
286 }
287 }
288
289 // CRITICAL FIX: Use atomic operations for splitting flag
290 if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
291 log.Printf("[workspace] stack ignored: operation already in progress")
292 return
293 }
294
295 // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
296 defer func() {
297 atomic.StoreInt32(&wm.splitting, 0)
298 if r := recover(); r != nil {
299 log.Printf("[workspace] stack operation panicked, flag cleared: %v", r)
300 panic(r) // Re-panic to maintain error handling
301 }
302 }()
303
304 wm.lastSplitMsg[source] = time.Now()
305
306 newNode, err := wm.stackedPaneManager.StackPane(node)
307 if err != nil {
308 log.Printf("[workspace] stack failed: %v", err)
309 // CRITICAL FIX: Pump GTK events after stack failure
310 if webkit.IsMainThread() {
311 webkit.IterateMainLoop()
312 }
313 return
314 }
315 wm.clonePaneState(node, newNode)
316 case "create-pane":
317 log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
318
319 if msg.URL == "" {
320 log.Printf("[workspace] create-pane: empty URL, ignoring")
321 break
322 }
323
324 // NEW: Get WebView ID for deduplication
325 webViewID := "unknown"
326 if source != nil {
327 // Try to get a unique identifier for the WebView
328 webViewID = fmt.Sprintf("%p", source)
329 }
330
331 // NEW: Create intent for deduplication check
332 intent := &messaging.WindowIntent{
333 URL: msg.URL,
334 WindowType: msg.Action,
335 Timestamp: time.Now().UnixMilli(),
336 RequestID: msg.RequestID,
337 UserTriggered: true,
338 }
339
340 // NEW: Check for duplicates
341 if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
342 log.Printf("[workspace] create-pane BLOCKED: %s", reason)
343 break
344 }
345
346 // Use the existing methods to handle tab vs popup creation
347 switch strings.ToLower(msg.Action) {
348 case "tab":
349 newView := wm.handleIntentAsTab(node, msg.URL, intent)
350 if newView != nil {
351 log.Printf("[workspace] create-pane: tab created successfully")
352 } else {
353 log.Printf("[workspace] create-pane: failed to create tab")
354 }
355 case "popup":
356 newView := wm.handleIntentAsPopup(node, msg.URL, intent)
357 if newView != nil {
358 log.Printf("[workspace] create-pane: popup created successfully")
359 } else {
360 log.Printf("[workspace] create-pane: failed to create popup")
361 }
362 default:
363 log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
364 newView := wm.handleIntentAsTab(node, msg.URL, intent)
365 if newView != nil {
366 log.Printf("[workspace] create-pane: default tab created successfully")
367 } else {
368 log.Printf("[workspace] create-pane: failed to create default tab")
369 }
370 }
371 case "close-popup":
372 log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
373
374 if msg.WebViewID == "" {
375 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
376 break
377 }
378
379 // Find the popup pane by webview ID
380 var targetNode *paneNode
381 for webView, node := range wm.viewToNode {
382 if webView != nil && webView.ID() == msg.WebViewID {
383 targetNode = node
384 break
385 }
386 }
387
388 if targetNode == nil {
389 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
390 break
391 }
392
393 if !targetNode.isPopup {
394 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
395 break
396 }
397
398 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
399
400 // Remove this popup from parent's activePopupChildren before closing
401 if targetNode.parentPane != nil {
402 parentNode := targetNode.parentPane
403 for i, childID := range parentNode.activePopupChildren {
404 if childID == msg.WebViewID {
405 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
406 log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren))
407 break
408 }
409 }
410
411 // Clean up ALL localStorage entries for this popup in parent WebView
412 // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc.
413 if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" {
414 parentWebViewID := parentNode.pane.WebView().ID()
415 requestID := targetNode.requestID
416
417 cleanupScript := fmt.Sprintf(`
418 try {
419 // Clean up popup_mapping_<parentId> (from Go code)
420 localStorage.removeItem('popup_mapping_%s');
421
422 // Clean up popup_<requestId>_* entries (from JavaScript)
423 const requestId = '%s';
424 const keysToRemove = [
425 'popup_' + requestId + '_parent_info',
426 'popup_' + requestId + '_parent_action',
427 'popup_' + requestId + '_message_to_popup',
428 'popup_' + requestId + '_message_to_parent',
429 'oauth_callback_' + '%s'
430 ];
431
432 keysToRemove.forEach(key => {
433 try {
434 localStorage.removeItem(key);
435 } catch(e) {}
436 });
437
438 console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId);
439 } catch(e) {
440 console.warn('[workspace] Failed to clean popup localStorage:', e);
441 }
442 `, parentWebViewID, requestID, msg.WebViewID)
443
444 if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil {
445 log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err)
446 } else {
447 log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID)
448 }
449 }
450 }
451
452 // Close the popup pane
453 if err := wm.ClosePane(targetNode); err != nil {
454 log.Printf("[workspace] Failed to close popup pane: %v", err)
455 } else {
456 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
457
458 // Fix GTK rendering for parent pane after popup closes
459 // The parent pane's rendering can become corrupted after popup closes
460 if targetNode.parentPane != nil {
461 parentNode := targetNode.parentPane
462 if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) {
463 log.Printf("[workspace] Fixing parent pane rendering after popup close")
464
465 // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline
466 webkit.WidgetHide(parentNode.container)
467
468 // Schedule showing and forcing GTK to reconnect rendering
469 wm.scheduleIdleGuarded(func() bool {
470 if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 {
471 return false
472 }
473 // Show widget to reconnect WebKit rendering pipeline
474 webkit.WidgetShow(parentNode.container)
475 // Force GTK to recalculate size and recreate rendering surface
476 webkit.WidgetQueueResize(parentNode.container)
477 webkit.WidgetQueueDraw(parentNode.container)
478
479 // Also queue resize+draw on the WebView widget itself
480 if parentNode.pane != nil && parentNode.pane.WebView() != nil {
481 webViewWidget := parentNode.pane.WebView().Widget()
482 if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
483 webkit.WidgetQueueResize(webViewWidget)
484 webkit.WidgetQueueDraw(webViewWidget)
485 log.Printf("[workspace] Queued resize+draw for parent WebView widget")
486 }
487 }
488
489 log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close")
490 return false
491 }, parentNode)
492 }
493 }
494 }
495 default:
496 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
497 }
498}
499
500// GetActiveNode returns the currently active pane node
501func (wm *WorkspaceManager) GetActiveNode() *paneNode {
502 return wm.focusStateMachine.GetActivePane()
503}
504
505// SetActivePane requests focus change through the focus state machine with debouncing
506func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
507 now := time.Now()
508
509 // Check if we should debounce this focus request (except for urgent system events)
510 if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
511 // Too soon and same target - ignore this request to prevent rapid oscillation
512 return
513 }
514
515 // Update tracking and proceed with focus change
516 wm.lastFocusTime = now
517 wm.lastFocusTarget = node
518
519 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
520 log.Printf("[workspace] Focus request failed: %v", err)
521 }
522}
523
524// GetNodeForWebView returns the pane node associated with a WebView
525func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
526 return wm.viewToNode[webView]
527}
528
529// RegisterNavigationHandler sets up navigation handling for a webview (simplified)
530func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
531 if webView == nil {
532 return
533 }
534
535 log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID())
536}