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// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
77
78// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
79func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
80 debugLevel := getDebugLevel()
81
82 manager := &WorkspaceManager{
83 app: app,
84 window: rootPane.webView.Window(),
85 viewToNode: make(map[*webkit.WebView]*paneNode),
86 lastSplitMsg: make(map[*webkit.WebView]time.Time),
87 lastExitMsg: make(map[*webkit.WebView]time.Time),
88 pendingPopups: make(map[uint64]*pendingPopup),
89 focusDebounce: 150 * time.Millisecond,
90 debugLevel: debugLevel,
91 }
92
93 // Initialize validation components (opt-in based on debug level)
94 if debugLevel >= DebugBasic {
95 manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
96 manager.geometryValidator = NewGeometryValidator()
97 manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
98 } else {
99 // Production mode: disable validators
100 manager.treeValidator = NewTreeValidator(false, false)
101 manager.geometryValidator = NewGeometryValidator()
102 }
103
104 // Initialize tree rebalancer (always needed for proper close operations)
105 manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
106
107 // Initialize stack lifecycle manager
108 manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
109
110 // Initialize existing specialized managers (now enhanced with bulletproof components)
111 manager.stackedPaneManager = NewStackedPaneManager(manager)
112 manager.focusStateMachine = NewFocusStateMachine(manager)
113 manager.createWebViewFn = func() (*webkit.WebView, error) {
114 if manager.app == nil {
115 return nil, errors.New("workspace manager missing app reference")
116 }
117 cfg, err := manager.app.buildWebkitConfig()
118 if err != nil {
119 return nil, err
120 }
121 // CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
122 // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
123 // prevents GTK "widget already has parent" errors when adding to paned containers.
124 cfg.CreateWindow = false
125 return webkit.NewWebView(cfg)
126 }
127 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
128 if manager.app == nil {
129 return nil, errors.New("workspace manager missing app reference")
130 }
131 return manager.app.createPaneForView(view)
132 }
133 manager.ensureWorkspaceStyles()
134
135 rootContainer := rootPane.webView.RootWidget()
136 root := &paneNode{
137 pane: rootPane,
138 isLeaf: true,
139 }
140 // Initialize widgets properly
141 manager.initializePaneWidgets(root, rootContainer)
142
143 manager.root = root
144 manager.mainPane = root
145 manager.viewToNode[rootPane.webView] = root
146 manager.ensureHover(root)
147
148 if handler := rootPane.MessageHandler(); handler != nil {
149 handler.SetWorkspaceObserver(manager)
150 }
151
152 app.workspace = manager
153
154 // Configure debug settings from app config
155 if app.config != nil {
156 manager.focusStateMachine.ConfigureDebug(
157 app.config.Debug.EnableFocusDebug,
158 false, // CSS debug removed
159 app.config.Debug.EnableFocusMetrics,
160 )
161
162 manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
163 }
164
165 manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
166
167 // Initialize focus state machine after all setup is complete
168 if err := manager.focusStateMachine.Initialize(); err != nil {
169 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
170 }
171
172 // Ensure hover controllers are attached to all panes for mouse-driven focus changes
173 manager.attachHoverHandlersToAllPanes()
174
175 if manager.debugPaneClose {
176 manager.dumpTreeState("workspace_init")
177 }
178
179 return manager
180}
181
182// shouldDebounce checks if an operation should be debounced based on timing
183func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
184 if last, ok := wm.lastSplitMsg[source]; ok {
185 if elapsed := time.Since(last); elapsed < threshold {
186 log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
187 return true
188 }
189 }
190 wm.lastSplitMsg[source] = time.Now()
191 return false
192}
193
194// withSplitLock executes an operation with atomic splitting lock protection
195func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
196 if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
197 log.Printf("[workspace] %s ignored: operation in progress", operation)
198 return fmt.Errorf("operation in progress")
199 }
200
201 defer func() {
202 atomic.StoreInt32(&wm.splitting, 0)
203 if r := recover(); r != nil {
204 log.Printf("[workspace] %s panicked: %v", operation, r)
205 panic(r)
206 }
207 }()
208
209 return fn()
210}
211
212// cleanupPopupStorage removes localStorage entries for a closed popup
213func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
214 if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
215 return
216 }
217
218 requestID := targetNode.requestID
219 parentWebViewID := parentNode.pane.WebView().ID()
220
221 script := fmt.Sprintf(`
222 try {
223 localStorage.removeItem('popup_mapping_%d');
224 const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
225 'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
226 'oauth_callback_%s'];
227 keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
228 console.log('[workspace] Cleaned popup localStorage');
229 } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
230 `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
231
232 if err := parentNode.pane.WebView().InjectScript(script); err != nil {
233 log.Printf("[workspace] localStorage cleanup failed: %v", err)
234 } else {
235 log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
236 }
237}
238
239// OnWorkspaceMessage implements messaging.WorkspaceObserver.
240func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
241 node := wm.viewToNode[source]
242
243 if msg.WebViewID != "" {
244 if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
245 if explicitNode.pane != nil && explicitNode.pane.webView != nil {
246 node = explicitNode
247 source = explicitNode.pane.webView
248 }
249 }
250 }
251
252 if node == nil {
253 log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
254 return
255 }
256
257 switch event := strings.ToLower(msg.Event); event {
258 case "pane-split":
259 wm.SetActivePane(node, SourceProgrammatic)
260 direction := strings.ToLower(msg.Direction)
261 if direction == "" {
262 direction = "right"
263 }
264
265 if wm.shouldDebounce(source, 200*time.Millisecond) {
266 return
267 }
268
269 err := wm.withSplitLock("split", func() error {
270 newNode, err := wm.SplitPane(node, direction)
271 if err != nil {
272 if glib.MainContextDefault().IsOwner() {
273 glib.MainContextDefault().Iteration(false)
274 }
275 return err
276 }
277 wm.clonePaneState(node, newNode)
278 return nil
279 })
280
281 if err != nil {
282 log.Printf("[workspace] split failed: %v", err)
283 }
284
285 case "pane-stack":
286 wm.SetActivePane(node, SourceProgrammatic)
287
288 if wm.shouldDebounce(source, 200*time.Millisecond) {
289 return
290 }
291
292 err := wm.withSplitLock("stack", func() error {
293 newNode, err := wm.stackedPaneManager.StackPane(node)
294 if err != nil {
295 if glib.MainContextDefault().IsOwner() {
296 glib.MainContextDefault().Iteration(false)
297 }
298 return err
299 }
300 wm.clonePaneState(node, newNode)
301 return nil
302 })
303
304 if err != nil {
305 log.Printf("[workspace] stack failed: %v", err)
306 }
307
308 case "close-popup":
309 if msg.WebViewID == "" {
310 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
311 return
312 }
313
314 // Find popup pane by webview ID
315 var targetNode *paneNode
316 for webView, node := range wm.viewToNode {
317 if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
318 targetNode = node
319 break
320 }
321 }
322
323 if targetNode == nil {
324 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
325 return
326 }
327
328 if !targetNode.isPopup {
329 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
330 return
331 }
332
333 log.Printf("[workspace] Closing popup due to %s", msg.Reason)
334
335 // Remove from parent's active popup list
336 if targetNode.parentPane != nil {
337 parentNode := targetNode.parentPane
338 for i, childID := range parentNode.activePopupChildren {
339 if childID == msg.WebViewID {
340 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
341 log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
342 break
343 }
344 }
345
346 wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
347 }
348
349 if err := wm.ClosePane(targetNode); err != nil {
350 log.Printf("[workspace] Failed to close popup: %v", err)
351 }
352
353 default:
354 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
355 }
356}
357
358func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
359 if wm == nil || id == "" {
360 return nil
361 }
362
363 for webView, node := range wm.viewToNode {
364 if webView != nil && webView.IDString() == id {
365 return node
366 }
367 }
368 return nil
369}
370
371// GetActiveNode returns the currently active pane node
372func (wm *WorkspaceManager) GetActiveNode() *paneNode {
373 if wm == nil || wm.focusStateMachine == nil {
374 return nil
375 }
376 return wm.focusStateMachine.GetActivePane()
377}
378
379// SetActivePane requests focus change through the focus state machine with debouncing
380func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
381 now := time.Now()
382
383 // Check if we should debounce this focus request (except for urgent system events)
384 if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
385 // Too soon and same target - ignore this request to prevent rapid oscillation
386 return
387 }
388
389 // Update tracking and proceed with focus change
390 wm.lastFocusTime = now
391 wm.lastFocusTarget = node
392
393 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
394 log.Printf("[workspace] Focus request failed: %v", err)
395 }
396}
397
398// GetNodeForWebView returns the pane node associated with a WebView
399func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
400 return wm.viewToNode[webView]
401}
402
403// RegisterNavigationHandler sets up navigation handling for a webview
404func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
405 if webView == nil {
406 return
407 }
408
409 // Register workspace navigation callback for alt+arrow pane navigation
410 webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
411 if wm == nil {
412 return false
413 }
414 return wm.FocusNeighbor(direction)
415 })
416
417 // Register pane mode callbacks for Ctrl+P and pane mode actions
418 webView.RegisterPaneModeHandler(func(action string) bool {
419 if wm == nil {
420 return false
421 }
422
423 if action == "enter" {
424 wm.EnterPaneMode()
425 return true
426 }
427
428 if action == "exit" {
429 // Check if pane mode is active
430 wm.paneMutex.Lock()
431 wasActive := wm.paneModeActive
432 wm.paneMutex.Unlock()
433
434 if wasActive {
435 wm.ExitPaneMode("escape")
436 return true // Mode was active, we handled it
437 }
438 return false // No mode active, pass through to DOM
439 }
440
441 // For other actions, only handle if pane mode is active
442 wm.paneMutex.Lock()
443 isActive := wm.paneModeActive
444 wm.paneMutex.Unlock()
445
446 if !isActive {
447 return false // Not in pane mode, allow normal behavior
448 }
449
450 // Pane mode is active, handle the action
451 wm.HandlePaneAction(action)
452 return true
453 }, func() bool {
454 // Check if pane mode is currently active
455 wm.paneMutex.Lock()
456 defer wm.paneMutex.Unlock()
457 return wm.paneModeActive
458 })
459
460 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
461}