v0.11.0 dc50343

workspace_manager.go RawHistoryFolder

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}

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