v0.12.0 d035193

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	"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}

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