v0.15.0 9006957

workspace_pane_ops.go RawHistoryFolder

1// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2package browser
3
4import (
5	"errors"
6	"fmt"
7
8	"github.com/diamondburned/gotk4/pkg/gdk/v4"
9	"github.com/diamondburned/gotk4/pkg/gtk/v4"
10
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/pkg/webkit"
13)
14
15// closeContext manages the context for a pane close operation
16type closeContext struct {
17	wm         *WorkspaceManager
18	target     *paneNode
19	remaining  int
20	err        error
21	generation uint
22}
23
24// beginClose initializes a close context with validation
25func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
26	ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
27	switch {
28	case wm == nil:
29		ctx.err = errors.New("workspace manager nil")
30	case node == nil || !node.isLeaf:
31		ctx.err = errors.New("invalid close target")
32	case node.pane == nil || node.pane.webView == nil:
33		ctx.err = errors.New("close target missing webview")
34	default:
35		ctx.remaining = len(wm.app.panes)
36	}
37	return ctx
38}
39
40// Generation returns the cleanup generation for this context
41func (ctx closeContext) Generation() uint {
42	return ctx.generation
43}
44
45// finish handles cleanup operations after close completion
46func (ctx closeContext) finish() {
47	if ctx.err == nil {
48		ctx.wm.updateMainPane()
49	}
50}
51
52// collectLeaves returns all leaf nodes in the workspace tree
53func (wm *WorkspaceManager) collectLeaves() []*paneNode {
54	return wm.collectLeavesFrom(wm.root)
55}
56
57// collectLeavesFromWithDirection collects leaves from a subtree with direction-aware stack entry
58func (wm *WorkspaceManager) collectLeavesFromWithDirection(node *paneNode, direction string) []*paneNode {
59	var leaves []*paneNode
60	visited := make(map[*paneNode]bool)
61
62	var walk func(*paneNode, int)
63	walk = func(n *paneNode, depth int) {
64		// Prevent infinite recursion and cycles
65		const maxDepth = 50
66		if n == nil || depth > maxDepth {
67			return
68		}
69		if visited[n] {
70			logging.Error(fmt.Sprintf("[workspace] collectLeavesFromWithDirection: cycle detected in tree"))
71			return
72		}
73		visited[n] = true
74
75		if n.isLeaf {
76			leaves = append(leaves, n)
77			return
78		}
79
80		// Handle stacked panes - select entry pane based on direction
81		if n.isStacked && len(n.stackedPanes) > 0 {
82			var entryIndex int
83			switch direction {
84			case DirectionDown:
85				// Coming from above - enter at first pane
86				entryIndex = 0
87			case DirectionUp:
88				// Coming from below - enter at last pane
89				entryIndex = len(n.stackedPanes) - 1
90			default:
91				// For horizontal directions, use active pane
92				entryIndex = n.activeStackIndex
93				if entryIndex < 0 || entryIndex >= len(n.stackedPanes) {
94					entryIndex = 0
95				}
96			}
97			if entryIndex >= 0 && entryIndex < len(n.stackedPanes) {
98				walk(n.stackedPanes[entryIndex], depth+1)
99			}
100			return
101		}
102
103		// Handle regular split nodes
104		walk(n.left, depth+1)
105		walk(n.right, depth+1)
106	}
107	walk(node, 0)
108	return leaves
109}
110
111// collectLeavesFrom collects all leaf nodes from a given subtree
112func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
113	var leaves []*paneNode
114	visited := make(map[*paneNode]bool)
115
116	var walk func(*paneNode, int)
117	walk = func(n *paneNode, depth int) {
118		// Prevent infinite recursion and cycles
119		const maxDepth = 50
120		if n == nil || depth > maxDepth {
121			return
122		}
123		if visited[n] {
124			logging.Error(fmt.Sprintf("[workspace] collectLeavesFrom: cycle detected in tree"))
125			return
126		}
127		visited[n] = true
128
129		if n.isLeaf {
130			leaves = append(leaves, n)
131			return
132		}
133
134		// Handle stacked panes - only include the currently ACTIVE pane as a leaf
135		// This prevents multiple CSS classes and ensures correct focus management
136		if n.isStacked && len(n.stackedPanes) > 0 {
137			activeIndex := n.activeStackIndex
138			if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
139				// Only the active pane in the stack counts as a leaf for focus/navigation
140				activePaneInStack := n.stackedPanes[activeIndex]
141				walk(activePaneInStack, depth+1)
142			}
143			return
144		}
145
146		// Handle regular split nodes
147		walk(n.left, depth+1)
148		walk(n.right, depth+1)
149	}
150	walk(node, 0)
151	return leaves
152}
153
154// createWebView creates a new WebView with the workspace's configuration
155func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
156	if wm == nil || wm.createWebViewFn == nil {
157		return nil, errors.New("workspace manager missing webview factory")
158	}
159	return wm.createWebViewFn()
160}
161
162// createPane creates a new BrowserPane for the given WebView
163func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
164	if wm == nil || wm.createPaneFn == nil {
165		return nil, errors.New("workspace manager missing pane factory")
166	}
167	pane, err := wm.createPaneFn(view)
168	if err != nil {
169		return nil, err
170	}
171
172	// Register the new WebView with the tab manager for progress tracking.
173	if wm.app != nil && wm.app.tabManager != nil {
174		wm.app.tabManager.registerWebViewForWorkspace(view, wm)
175	}
176
177	return pane, nil
178}
179
180// clonePaneState sets up a new pane with cloned state from another pane
181func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
182	if wm == nil || target == nil {
183		logging.Debug(fmt.Sprintf("[workspace] clonePaneState: wm or target is nil"))
184		return
185	}
186	if target.pane == nil || target.pane.webView == nil {
187		logging.Debug(fmt.Sprintf("[workspace] clonePaneState: target pane or webView is nil"))
188		return
189	}
190
191	// Load about:blank and auto-open omnibox
192	const blankURL = "about:blank"
193
194	logging.Debug(fmt.Sprintf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane))
195	if err := target.pane.webView.LoadURL(blankURL); err != nil {
196		logging.Error(fmt.Sprintf("[workspace] failed to load blank page in new pane: %v", err))
197	} else {
198		logging.Debug(fmt.Sprintf("[workspace] successfully initiated load of %s", blankURL))
199	}
200}
201
202// safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting.
203// GTK4 automatically removes controllers when widgets are unparented, so we just clear our
204// references and mark for reattachment. Manually removing controllers causes GTK bloom filter
205// corruption (double-removal assertion failure).
206func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
207	if wm == nil || node == nil {
208		return
209	}
210
211	markForDetachment := func(target *paneNode) {
212		if target == nil {
213			return
214		}
215
216		// Just mark for reattachment and clear our references
217		// GTK4 will auto-remove controllers during unparent
218		if target.hoverToken != nil {
219			target.pendingHoverReattach = true
220			target.hoverToken = nil // Clear reference, don't manually remove
221			logging.Debug(fmt.Sprintf("[workspace] Marked hover controller for reattach on pane %p (GTK will auto-remove)", target))
222		}
223
224		if target.focusControllerToken != 0 {
225			target.pendingFocusReattach = true
226			target.focusControllerToken = 0 // Clear reference, don't manually remove
227			logging.Debug(fmt.Sprintf("[workspace] Marked focus controller for reattach on pane %p (GTK will auto-remove)", target))
228		}
229	}
230
231	if node.isStacked {
232		for _, child := range node.stackedPanes {
233			markForDetachment(child)
234		}
235		return
236	}
237
238	if node.isLeaf {
239		markForDetachment(node)
240	}
241}
242
243// closeCurrentPane closes the currently focused pane
244func (wm *WorkspaceManager) closeCurrentPane() {
245	if wm == nil || wm.GetActiveNode() == nil {
246		return
247	}
248	if err := wm.ClosePane(wm.GetActiveNode()); err != nil {
249		logging.Error(fmt.Sprintf("[workspace] close current pane failed: %v", err))
250	}
251}
252
253// leftmostLeaf finds the leftmost leaf node in a subtree
254func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
255	for node != nil && !node.isLeaf {
256		if node.isStacked {
257			if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
258				candidate := node.stackedPanes[node.activeStackIndex]
259				if candidate != nil {
260					return candidate
261				}
262			}
263			if len(node.stackedPanes) > 0 {
264				return node.stackedPanes[0]
265			}
266			return nil
267		}
268		if node.left != nil {
269			node = node.left
270			continue
271		}
272		node = node.right
273	}
274	return node
275}
276
277// findReplacementRoot finds a suitable replacement when closing the current root pane
278func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
279	if wm == nil || wm.root == nil {
280		return nil
281	}
282
283	// If root is being closed and there are other panes, find a replacement
284	leaves := wm.collectLeaves()
285	for _, leaf := range leaves {
286		if leaf != excludeNode && leaf != nil && leaf.isLeaf {
287			// Find the topmost ancestor that's not the current root
288			current := leaf
289			for current.parent != nil && current.parent != wm.root {
290				current = current.parent
291			}
292
293			// If this leaf is a direct child of root, or root only has one subtree,
294			// we can promote this subtree to be the new root
295			if current.parent == wm.root {
296				// If the sibling is being excluded, promote this subtree
297				var sibling *paneNode
298				if wm.root.left == current {
299					sibling = wm.root.right
300				} else {
301					sibling = wm.root.left
302				}
303
304				if sibling == excludeNode {
305					// The sibling is being closed, so promote this subtree
306					return current
307				}
308			}
309
310			// Otherwise, return the first suitable leaf
311			return leaf
312		}
313	}
314
315	return nil
316}
317
318// updateMainPane updates the main pane reference based on current state
319func (wm *WorkspaceManager) updateMainPane() {
320	if len(wm.app.panes) == 1 {
321		if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
322			wm.mainPane = leaf
323		}
324		return
325	}
326
327	if wm.mainPane == nil || !wm.mainPane.isLeaf {
328		if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
329			wm.mainPane = wm.GetActiveNode()
330		}
331	}
332}
333
334// Helper methods to support clean pane removal from workspace tracking
335// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
336
337// removeFromMaps removes a WebView from all workspace tracking maps
338func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
339	if wm == nil || webView == nil {
340		return
341	}
342
343	delete(wm.viewToNode, webView)
344	delete(wm.lastSplitMsg, webView)
345	delete(wm.lastExitMsg, webView)
346	logging.Debug(fmt.Sprintf("[workspace] removed webview %p from tracking maps", webView))
347}
348
349// removeFromAppPanes removes a BrowserPane from the app.panes slice
350func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
351	if wm == nil || wm.app == nil || pane == nil {
352		return
353	}
354
355	for i, p := range wm.app.panes {
356		if p == pane {
357			wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
358			logging.Debug(fmt.Sprintf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes)))
359			return
360		}
361	}
362}
363
364// insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter.
365// This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
366// See splitNode() for the unified implementation.
367
368// TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30)
369//
370// splitNode splits a target pane into two panes in the specified direction
371// splitNode splits a target pane into two panes in the specified direction.
372// If existingPane is provided, it will be used instead of creating a new WebView.
373// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
374func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
375	if target == nil || !target.isLeaf || target.pane == nil {
376		return nil, errors.New("split target must be a leaf pane")
377	}
378
379	// IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
380	// not the individual pane or the stack itself. We create a new split at the stack's parent level.
381	originalTarget := target
382	var stackContainer *paneNode
383	if target.parent != nil && target.parent.isStacked {
384		// Target is in a stack - we'll create the split around the entire stack
385		stackContainer = target.parent
386		logging.Debug(fmt.Sprintf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer))
387	}
388
389	// Determine what we're actually splitting
390	var splitTarget *paneNode
391	var splitTargetContainer gtk.Widgetter
392
393	if stackContainer != nil {
394		// Case: splitting from inside a stack - we split around the entire stack
395		splitTarget = stackContainer
396		splitTargetContainer = stackContainer.container
397		logging.Debug(fmt.Sprintf("[workspace] splitting around stack: stackContainer=%p", splitTarget))
398	} else {
399		// Case: normal split from a simple pane
400		splitTarget = target
401		splitTargetContainer = target.container
402		logging.Info(fmt.Sprintf("[workspace] normal split from pane: target=%p", splitTarget))
403	}
404
405	// Use provided pane or create new one
406	var newPane *BrowserPane
407
408	if existingPane != nil {
409		// Reuse existing pane (for popup insertion)
410		newPane = existingPane
411		logging.Debug(fmt.Sprintf("[workspace] Using existing pane for split: %p", newPane))
412	} else {
413		// Create new WebView and pane (for normal splits)
414		newView, err := wm.createWebView()
415		if err != nil {
416			return nil, err
417		}
418
419		newPane, err = wm.createPane(newView)
420		if err != nil {
421			return nil, err
422		}
423		logging.Info(fmt.Sprintf("[workspace] Created new pane for split: %p", newPane))
424	}
425
426	if handler := newPane.MessageHandler(); handler != nil {
427		handler.SetWorkspaceObserver(wm)
428	}
429
430	newContainer := newPane.webView.RootWidget()
431	if newContainer == nil {
432		return nil, errors.New("new pane missing container")
433	}
434	webkit.WidgetSetHExpand(newContainer, true)
435	webkit.WidgetSetVExpand(newContainer, true)
436
437	// Use the determined split target container
438	if splitTargetContainer == nil {
439		return nil, errors.New("split target missing container")
440	}
441
442	// Only apply GTK operations if this is a simple widget (leaf pane)
443	// For stacked containers, we need to be more careful
444	if splitTarget.isLeaf {
445		if splitTargetContainer != nil {
446			webkit.WidgetSetHExpand(splitTargetContainer, true)
447			webkit.WidgetSetVExpand(splitTargetContainer, true)
448		}
449	} else if splitTarget.isStacked {
450		// Stack containers need the same setup as regular panes for proper splitting
451		if splitTargetContainer != nil {
452			webkit.WidgetSetHExpand(splitTargetContainer, true)
453			webkit.WidgetSetVExpand(splitTargetContainer, true)
454			logging.Debug(fmt.Sprintf("[workspace] configured stack wrapper for split: %p", splitTargetContainer))
455		}
456	}
457
458	orientation, existingFirst := mapDirection(direction)
459	logging.Debug(fmt.Sprintf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
460		direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil))
461
462	paned := gtk.NewPaned(orientation)
463	if paned == nil {
464		return nil, errors.New("failed to create GtkPaned")
465	}
466	paned.SetHExpand(true)
467	paned.SetVExpand(true)
468
469	// Allow both children to resize; avoids shrinking existing pane to zero during root splits
470	paned.SetResizeStartChild(true)
471	paned.SetResizeEndChild(true)
472
473	newLeaf := &paneNode{
474		pane:   newPane,
475		isLeaf: true,
476	}
477	wm.initializePaneWidgets(newLeaf, newContainer)
478
479	// After wrapping the WebView in an overlay, use the overlay container for layout operations.
480	newContainer = newLeaf.container
481	if newContainer == nil {
482		return nil, errors.New("new pane missing container overlay")
483	}
484	webkit.WidgetSetHExpand(newContainer, true)
485	webkit.WidgetSetVExpand(newContainer, true)
486
487	split := &paneNode{
488		parent:      splitTarget.parent,
489		orientation: orientation,
490		isLeaf:      false,
491	}
492	wm.initializePaneWidgets(split, paned)
493
494	parent := split.parent
495
496	// Clear CSS classes before reparenting to avoid GTK bloom filter corruption
497	// NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
498	// The focus state machine will apply the active class to the new pane via SetActivePane()
499	if splitTargetContainer != nil {
500		// Remove active border class if present (prevents GTK bloom filter corruption during unparent)
501		if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
502			webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
503			logging.Debug(fmt.Sprintf("[workspace] removed active class from split target (will transfer to new pane)"))
504		}
505	}
506
507	// Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
508	wm.safelyDetachControllersBeforeReparent(splitTarget)
509
510	// For non-root splits, unparent from parent paned
511	if parent != nil && parent.container != nil {
512		// Split target has a parent paned - remove it (automatically unparents in GTK4)
513		logging.Debug(fmt.Sprintf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container))
514		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
515			if parent.left == splitTarget {
516				parentPaned.SetStartChild(nil)
517			} else if parent.right == splitTarget {
518				parentPaned.SetEndChild(nil)
519			}
520			// GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
521			webkit.WidgetQueueAllocate(parent.container)
522		}
523	}
524	// NOTE: For root splits (parent == nil), we DON'T unparent here.
525	// We'll unparent immediately before adding to paned below to minimize orphan time.
526
527	// Set up the tree structure first
528	if existingFirst {
529		split.left = splitTarget
530		split.right = newLeaf
531	} else {
532		split.left = newLeaf
533		split.right = splitTarget
534	}
535
536	splitTarget.parent = split
537	newLeaf.parent = split
538
539	// Update tree root/parent references
540	if parent == nil {
541		wm.root = split
542	} else {
543		if parent.left == splitTarget {
544			parent.left = split
545		} else if parent.right == splitTarget {
546			parent.right = split
547		}
548	}
549
550	// GTK4 handles widget operations automatically - no need to force redraw
551
552	// Handle root split: attach paned to ContentArea (tab system is always present)
553	if parent == nil {
554		// ROOT SPLIT: Manipulate TabManager.ContentArea
555		if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
556			logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot perform root split - tab manager not initialized"))
557			return nil, fmt.Errorf("tab manager not initialized")
558		}
559
560		contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
561		if !ok || contentBox == nil {
562			logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
563			return nil, fmt.Errorf("ContentArea is not a Box")
564		}
565
566		// Remove the old workspace root from ContentArea
567		contentBox.Remove(splitTargetContainer)
568		logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer))
569
570		// Attach the new paned to ContentArea FIRST
571		contentBox.Append(paned)
572		logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned))
573
574		// Now add children to the paned - GTK will handle reparenting automatically
575		if existingFirst {
576			paned.SetStartChild(splitTargetContainer)
577			paned.SetEndChild(newContainer)
578			logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer))
579		} else {
580			paned.SetStartChild(newContainer)
581			paned.SetEndChild(splitTargetContainer)
582			logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer))
583		}
584	} else {
585		// NON-ROOT SPLIT: Standard approach
586
587		// Add both containers to the new paned
588		if existingFirst {
589			if splitTargetContainer != nil {
590				paned.SetStartChild(splitTargetContainer)
591				paned.SetEndChild(newContainer)
592				logging.Debug(fmt.Sprintf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer))
593			}
594		} else {
595			if splitTargetContainer != nil {
596				paned.SetStartChild(newContainer)
597				paned.SetEndChild(splitTargetContainer)
598				logging.Debug(fmt.Sprintf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer))
599			}
600		}
601
602		// Attach the new paned to its parent
603		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
604			if parent.left == split {
605				parentPaned.SetStartChild(paned)
606			} else if parent.right == split {
607				parentPaned.SetEndChild(paned)
608			}
609			webkit.WidgetQueueAllocate(parent.container)
610			paned.QueueAllocate()
611			logging.Info(fmt.Sprintf("[workspace] paned inserted into parent=%p", parent.container))
612		}
613	}
614
615	paned.Show()
616
617	// Synchronize the divider once the paned has a meaningful allocation.
618	wm.syncPanedDivider(paned, orientation, split)
619
620	// GTK4 automatically handles widget rendering after reparenting - no manual updates needed
621
622	wm.viewToNode[newPane.webView] = newLeaf
623	wm.setupPopupHandling(newPane.webView, newLeaf)
624	wm.ensureHover(newLeaf)
625
626	// Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
627	// The originalTarget is still in the stack and should not interfere with the new split pane
628	if originalTarget == target {
629		// Only ensure hover on target if it's a regular (non-stack) split
630		if !target.pendingHoverReattach {
631			wm.ensureHover(target)
632		}
633	}
634
635	// Only add to app.panes if this is a new pane (not an existing popup pane)
636	if existingPane == nil {
637		wm.app.panes = append(wm.app.panes, newPane)
638	} else {
639		// For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
640		logging.Info(fmt.Sprintf("[workspace] Skipping app.panes append for existing popup pane (already added)"))
641	}
642
643	if newPane.zoomController != nil {
644		newPane.zoomController.ApplyInitialZoom()
645	}
646
647	// Update CSS classes for all panes now that we have multiple panes
648
649	reattachTargets := []*paneNode{splitTarget}
650	if splitTarget != nil && splitTarget.isStacked {
651		reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
652	}
653	guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
654	wm.scheduleIdleGuarded(func() bool {
655		if newLeaf == nil || !newLeaf.widgetValid {
656			return false
657		}
658		if newContainer != nil {
659			webkit.WidgetShow(newContainer)
660		}
661
662		// CRITICAL: For root splits, also show the split target container after reparenting
663		// When the split target is reparented from window → paned, GTK doesn't preserve visibility
664		if parent == nil && splitTargetContainer != nil {
665			webkit.WidgetShow(splitTargetContainer)
666			webkit.WidgetQueueAllocate(splitTargetContainer)
667			webkit.WidgetQueueDraw(splitTargetContainer)
668		}
669
670		// Ensure the underlying WebView widget is visible after reparenting
671		if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
672			if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
673				webkit.WidgetShow(webViewWidget)
674				webkit.WidgetQueueAllocate(webViewWidget)
675				webkit.WidgetQueueDraw(webViewWidget)
676			}
677		}
678
679		// Attach GTK focus controller to new pane
680		if wm.focusStateMachine != nil {
681			wm.focusStateMachine.attachGTKController(newLeaf)
682		}
683
684		for _, candidate := range reattachTargets {
685			if candidate == nil || !candidate.widgetValid {
686				continue
687			}
688			if candidate.pendingHoverReattach {
689				wm.ensureHover(candidate)
690				if candidate.hoverToken != nil {
691					candidate.pendingHoverReattach = false
692				}
693			}
694			if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
695				wm.focusStateMachine.attachGTKController(candidate)
696				if candidate.focusControllerToken != 0 {
697					candidate.pendingFocusReattach = false
698				}
699			}
700		}
701		wm.SetActivePane(newLeaf, SourceSplit)
702		return false
703	}, guardNodes...)
704
705	return newLeaf, nil
706}
707
708// Helper functions for the simplified closePane implementation
709
710// getSibling returns the sibling node for a given node
711func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
712	if node.parent == nil {
713		return nil
714	}
715	if node.parent.left == node {
716		return node.parent.right
717	}
718	return node.parent.left
719}
720
721// nextCleanupGeneration returns the next cleanup generation counter
722func (wm *WorkspaceManager) nextCleanupGeneration() uint {
723	wm.cleanupCounter++
724	return wm.cleanupCounter
725}
726
727// promoteSibling promotes a sibling node to replace its parent in the tree
728func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
729	if grand == nil {
730		wm.root = sibling
731		sibling.parent = nil
732		return
733	}
734	sibling.parent = grand
735	if grand.left == parent {
736		grand.left = sibling
737	} else {
738		grand.right = sibling
739	}
740}
741
742// swapContainers updates GTK widget hierarchy for promoted siblings
743func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) {
744	if grand == nil {
745		// Sibling is being promoted to root
746		// When TreeRebalancer is enabled, skip GTK attachment here as it will be
747		// handled by the rebalancer's promotion transaction to avoid double-attachment
748		if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
749			// NOTE: Can't use attachRoot() here because wm.root has already been updated
750			// to point to the sibling by promoteSibling(). We need to manually swap in ContentArea:
751			// the old paned (parent) is still in ContentArea and needs to be removed,
752			// then the sibling needs to be added.
753
754			// The sibling has already been unparented from the old paned earlier in the close logic,
755			// so it has no GTK parent now.
756
757			if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
758				logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized"))
759				return
760			}
761
762			contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
763			if !ok || contentBox == nil {
764				logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
765				return
766			}
767
768			// CRITICAL: Remove the old paned from ContentArea first
769			if parent != nil && parent.container != nil {
770				contentBox.Remove(parent.container)
771				logging.Debug(fmt.Sprintf("[workspace] Removed old paned %p from ContentArea", parent.container))
772			}
773
774			// Now add sibling to ContentArea as the new root
775			contentBox.Append(sibling.container)
776			webkit.WidgetSetVisible(sibling.container, true)
777			logging.Debug(fmt.Sprintf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container))
778		}
779		return
780	}
781	// Clear CSS classes before reparenting to avoid GTK bloom filter corruption
782	if sibling.container != nil {
783		if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
784			webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
785		}
786	}
787
788	if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
789		// GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
790		// Focus was already cleared before unparenting closed pane
791		if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
792			logging.Debug(fmt.Sprintf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container))
793		}
794
795		if grand.left == sibling {
796			grandPaned.SetStartChild(sibling.container)
797			logging.Debug(fmt.Sprintf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container))
798		} else {
799			grandPaned.SetEndChild(sibling.container)
800			logging.Debug(fmt.Sprintf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container))
801		}
802		webkit.WidgetQueueAllocate(grand.container)
803	}
804}
805
806// cascadePromotion handles the case where a paned node has only one child
807// This happens when closing a pane leaves the grandparent with only one child
808func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
809	if singleChildPaned == nil {
810		return
811	}
812
813	// Find the single child
814	var onlyChild *paneNode
815	if singleChildPaned.left != nil && singleChildPaned.right == nil {
816		onlyChild = singleChildPaned.left
817	} else if singleChildPaned.right != nil && singleChildPaned.left == nil {
818		onlyChild = singleChildPaned.right
819	} else {
820		// Either no children or two children - nothing to cascade
821		return
822	}
823
824	logging.Info(fmt.Sprintf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild))
825
826	greatGrandparent := singleChildPaned.parent
827
828	// Update tree structure
829	onlyChild.parent = greatGrandparent
830	if greatGrandparent == nil {
831		// Single child becomes new root
832		wm.root = onlyChild
833		logging.Debug(fmt.Sprintf("[workspace] Only child %p promoted to root", onlyChild))
834
835		// Attach to window
836		if onlyChild.container != nil {
837			wm.attachRoot(onlyChild)
838		}
839
840		// Cleanup the now-orphaned paned
841		wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
842	} else {
843		// Replace paned in great-grandparent
844		if greatGrandparent.left == singleChildPaned {
845			greatGrandparent.left = onlyChild
846		} else {
847			greatGrandparent.right = onlyChild
848		}
849
850		logging.Debug(fmt.Sprintf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent))
851
852		// Reparent widget in GTK
853		if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
854			// GTK4 PanedSet*Child automatically handles unparenting
855			if greatGrandparent.left == onlyChild {
856				greatGrandPaned.SetStartChild(onlyChild.container)
857			} else {
858				greatGrandPaned.SetEndChild(onlyChild.container)
859			}
860
861			webkit.WidgetSetHExpand(onlyChild.container, true)
862			webkit.WidgetSetVExpand(onlyChild.container, true)
863			webkit.WidgetQueueAllocate(onlyChild.container)
864			webkit.WidgetQueueAllocate(greatGrandparent.container)
865			wm.scheduleIdleGuarded(func() bool {
866				if onlyChild == nil || !onlyChild.widgetValid {
867					return false
868				}
869				if onlyChild.container != nil {
870					webkit.WidgetShow(onlyChild.container)
871					webkit.WidgetQueueResize(onlyChild.container)
872					webkit.WidgetQueueDraw(onlyChild.container)
873				}
874				return false
875			}, onlyChild)
876		}
877
878		// Cleanup the now-orphaned paned
879		wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
880
881		// Check if great-grandparent now also has only one child (recursive case)
882		if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
883			logging.Info(fmt.Sprintf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent))
884			wm.cascadePromotion(greatGrandparent)
885		}
886	}
887}
888
889// attachRoot attaches a node as the new root in TabManager.ContentArea.
890// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
891// The old root container (if any) will be orphaned in ContentArea and garbage collected.
892func (wm *WorkspaceManager) attachRoot(root *paneNode) {
893	if root == nil || root.container == nil {
894		return
895	}
896
897	// Note: In gotk4, reference counting is handled automatically by Go's GC
898	// No need for manual Ref()/Unref() calls
899
900	// Detach from previous container (paned, stack, etc.) before attaching to ContentArea
901	// While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
902	if parent := webkit.WidgetGetParent(root.container); parent != nil {
903		logging.Debug(fmt.Sprintf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent))
904		webkit.WidgetUnparent(root.container)
905		// Note: In gotk4, the Go object reference remains valid after unparent
906		// Only the GTK parent relationship is cleared
907	}
908
909	if root.container != nil {
910		webkit.WidgetSetHExpand(root.container, true)
911		webkit.WidgetSetVExpand(root.container, true)
912	}
913
914	// Add to ContentArea (the old root will be orphaned and garbage collected)
915	if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
916		logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot attach root - tab manager not initialized"))
917		return
918	}
919
920	contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
921	if !ok || contentBox == nil {
922		logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
923		return
924	}
925
926	contentBox.Append(root.container)
927	webkit.WidgetSetVisible(root.container, true)
928	if root.container != nil {
929		webkit.WidgetQueueAllocate(root.container)
930		webkit.WidgetShow(root.container)
931		webkit.WidgetQueueResize(root.container)
932		webkit.WidgetQueueDraw(root.container)
933	}
934
935	wm.scheduleIdleGuarded(func() bool {
936		if root == nil || !root.widgetValid {
937			return false
938		}
939		if root.container != nil {
940			webkit.WidgetQueueResize(root.container)
941			webkit.WidgetQueueDraw(root.container)
942		}
943		return false
944	}, root)
945
946	// Verify attachment succeeded
947	if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
948		logging.Warn(fmt.Sprintf("[workspace] WARNING: widget %p has no parent after SetChild", root.container))
949	} else {
950		logging.Debug(fmt.Sprintf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent))
951	}
952}
953
954// cleanupPane safely cleans up a pane node with generation tracking
955func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
956	if node == nil {
957		return
958	}
959	if !node.widgetValid {
960		return
961	}
962
963	if wm.focusStateMachine != nil {
964		wm.focusStateMachine.InvalidateActivePane(node)
965	}
966
967	wm.cancelIdleHandles(node)
968
969	node.widgetValid = false
970	node.cleanupGeneration = generation
971
972	if node.pane != nil {
973		pane := node.pane
974		pane.Cleanup()
975		pane.CleanupFromWorkspace(wm)
976		if pane.webView != nil {
977			logging.Debug(fmt.Sprintf("[workspace] releasing WebView reference: %p", pane.webView))
978			pane.webView = nil
979		}
980		node.pane = nil
981	}
982
983	if node.container != nil {
984		// Container widget will be destroyed by GTK when unparented
985		node.container = nil
986	}
987
988	node.pendingIdleHandles = nil
989	node.parent = nil
990	node.left = nil
991	node.right = nil
992}
993
994// decommissionParent cleans up a parent node after promotion
995func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
996	if parent == nil {
997		return
998	}
999
1000	// For branch nodes (paneds), destroy the widget
1001	// Children have already been unparented during promotion/close, no need to clear them
1002	if !parent.isLeaf && parent.container != nil {
1003		// In gotk4, we don't need to manually destroy - GTK handles this when unparented
1004		// Just mark it for cleanup
1005		logging.Debug(fmt.Sprintf("[workspace] marked paned widget for cleanup: %p", parent.container))
1006	}
1007
1008	wm.cleanupPane(parent, generation)
1009}
1010
1011// promoteNewRoot handles root replacement when closing the root pane
1012func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
1013	candidate := wm.findReplacementRoot(oldRoot)
1014	if candidate == nil {
1015		return wm.cleanupAndExit(oldRoot)
1016	}
1017
1018	sibling := wm.getSibling(candidate)
1019	if sibling != nil {
1020		wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
1021	}
1022
1023	candidate.parent = nil
1024	wm.root = candidate
1025
1026	wm.attachRoot(candidate)
1027
1028	wm.cleanupPane(oldRoot, ctx.Generation())
1029	return candidate, nil
1030}
1031
1032// cleanupAndExit handles the final pane cleanup and application exit
1033func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1034	logging.Info(fmt.Sprintf("[workspace] closing final pane; exiting browser"))
1035	wm.cleanupPane(node, wm.nextCleanupGeneration())
1036	wm.detachHover(node)
1037
1038	// Gracefully quit the GTK main loop
1039	webkit.QuitMainLoop()
1040	return nil, nil
1041}
1042
1043// setFocusToLeaf sets focus to the leftmost leaf of a node
1044func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
1045	focusTarget := wm.leftmostLeaf(node)
1046	if focusTarget != nil {
1047		wm.SetActivePane(focusTarget, SourceClose)
1048	}
1049}
1050
1051// TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30)
1052//
1053// closePane closes a specific pane and handles tree restructuring (simplified implementation)
1054func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1055	ctx := wm.beginClose(node)
1056	defer ctx.finish()
1057
1058	// STEP 1: Basic validation (quick fail)
1059	if ctx.err != nil {
1060		return nil, ctx.err
1061	}
1062
1063	// STEP 2: Handle stacked panes via compatibility shim
1064	if node.parent != nil && node.parent.isStacked {
1065		return wm.closeStackedPaneCompat(node)
1066	}
1067
1068	// Ensure we detach hover/focus controllers before the widget hierarchy changes.
1069	wm.detachHover(node)
1070	wm.detachFocus(node)
1071
1072	// STEP 3: Handle trivial exit cases
1073	// Don't exit if closing a popup - popups aren't counted in app.panes
1074	if ctx.remaining == 1 && !node.isPopup {
1075		return wm.cleanupAndExit(node)
1076	}
1077	if node == wm.root {
1078		return wm.promoteNewRoot(ctx, node)
1079	}
1080
1081	// STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
1082	parent := node.parent
1083	sibling := wm.getSibling(node)
1084	grandparent := parent.parent
1085
1086	// Clear grandparent's focus child BEFORE unparenting closed pane
1087	// GTK propagates focus events upward, so we must clear grandparent focus first
1088	if grandparent != nil && grandparent.container != nil {
1089		webkit.WidgetSetFocusChild(grandparent.container, nil)
1090	}
1091
1092	// Unparent the closed pane's container from the paned BEFORE promoting sibling
1093	if node.container != nil {
1094		if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1095			if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1096				if parent.left == node {
1097					parentPaned.SetStartChild(nil)
1098				} else if parent.right == node {
1099					parentPaned.SetEndChild(nil)
1100				}
1101				logging.Debug(fmt.Sprintf("[workspace] unparented closed pane container: %p", node.container))
1102			}
1103		}
1104	}
1105
1106	// STEP 5: Unparent sibling from parent paned BEFORE promotion
1107	// The sibling needs to be detached from the parent paned before we can attach it elsewhere
1108	if sibling.container != nil && parent.container != nil {
1109		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1110			if parent.left == sibling {
1111				parentPaned.SetStartChild(nil)
1112			} else if parent.right == sibling {
1113				parentPaned.SetEndChild(nil)
1114			}
1115			logging.Debug(fmt.Sprintf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container))
1116		}
1117	}
1118
1119	// STEP 6: Promote sibling in-place (tree structure)
1120	wm.promoteSibling(grandparent, parent, sibling)
1121
1122	// STEP 7: GTK widget reparenting
1123	// For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1124	// For root promotion: we need to remove the old paned from ContentArea
1125	wm.swapContainers(grandparent, parent, sibling)
1126
1127	// Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1128	if sibling != nil && sibling.container != nil {
1129		wm.scheduleIdleGuarded(func() bool {
1130			if sibling == nil || !sibling.widgetValid {
1131				return false
1132			}
1133			if sibling.container != nil {
1134				webkit.WidgetShow(sibling.container)
1135				webkit.WidgetQueueResize(sibling.container)
1136				webkit.WidgetQueueDraw(sibling.container)
1137			}
1138			return false
1139		}, sibling)
1140	}
1141
1142	// STEP 8: Check if grandparent now has only one child (cascade promotion needed)
1143	if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1144		// Grandparent has exactly one child - it should be promoted too
1145		logging.Info(fmt.Sprintf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent))
1146		wm.cascadePromotion(grandparent)
1147	}
1148
1149	// STEP 9: Cleanup & focus
1150	wm.cleanupPane(node, ctx.Generation())
1151	wm.decommissionParent(parent, ctx.Generation())
1152	if sibling != nil {
1153		wm.scheduleIdleGuarded(func() bool {
1154			if sibling == nil || !sibling.widgetValid {
1155				return false
1156			}
1157			wm.setFocusToLeaf(sibling)
1158			return false
1159		}, sibling)
1160	}
1161
1162	return sibling, nil
1163}
1164
1165// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1166func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1167	stack := node.parent
1168
1169	// Find index
1170	index := -1
1171	for i, pane := range stack.stackedPanes {
1172		if pane == node {
1173			index = i
1174			break
1175		}
1176	}
1177
1178	if index == -1 {
1179		return nil, errors.New("pane not in stack")
1180	}
1181
1182	// Remove from array
1183	stack.stackedPanes = append(
1184		stack.stackedPanes[:index],
1185		stack.stackedPanes[index+1:]...,
1186	)
1187
1188	// If only one pane left, unstack it
1189	if len(stack.stackedPanes) == 1 {
1190		remaining := stack.stackedPanes[0]
1191
1192		// Replace stack with remaining pane in tree
1193		remaining.parent = stack.parent
1194		if stack.parent != nil {
1195			if stack.parent.left == stack {
1196				stack.parent.left = remaining
1197			} else {
1198				stack.parent.right = remaining
1199			}
1200
1201			// Update GTK widgets
1202			if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
1203				if stack.parent.left == remaining {
1204					parentPaned.SetStartChild(remaining.container)
1205				} else {
1206					parentPaned.SetEndChild(remaining.container)
1207				}
1208			}
1209		} else {
1210			// Stack was root - attach remaining pane as new root
1211			wm.root = remaining
1212			if remaining.container != nil {
1213				wm.setRootContainer(remaining.container)
1214			}
1215		}
1216
1217		// Cleanup stack container
1218		wm.cleanupPane(stack, wm.nextCleanupGeneration())
1219
1220		return remaining, nil
1221	}
1222
1223	// Update active index if needed
1224	if stack.activeStackIndex >= len(stack.stackedPanes) {
1225		stack.activeStackIndex = len(stack.stackedPanes) - 1
1226	}
1227
1228	// Remove widget from GTK box
1229	if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1230		stackBox.Remove(node.container)
1231	}
1232
1233	// Show new active pane
1234	if stack.activeStackIndex >= 0 {
1235		active := stack.stackedPanes[stack.activeStackIndex]
1236		if active.container != nil {
1237			webkit.WidgetShow(active.container)
1238		}
1239	}
1240
1241	// Cleanup closed pane
1242	wm.cleanupPane(node, wm.nextCleanupGeneration())
1243
1244	return stack, nil
1245}
1246
1247// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1248// the divider to an even split. GTK often reports near-zero allocations for a few frames
1249// after insertion, so we retry on map/tick/idle until the size stabilizes.
1250func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1251	if wm == nil || paned == nil {
1252		return
1253	}
1254
1255	const (
1256		minDimension = 32
1257		maxFrames    = 120
1258	)
1259
1260	setPosition := func() bool {
1261		if guard != nil && !guard.widgetValid {
1262			return true
1263		}
1264
1265		alloc := paned.Allocation()
1266		dimension := alloc.Width()
1267		if orientation == gtk.OrientationVertical {
1268			dimension = alloc.Height()
1269		}
1270
1271		if dimension < minDimension {
1272			return false
1273		}
1274
1275		pos := dimension / 2
1276		if pos <= 0 {
1277			return false
1278		}
1279
1280		paned.SetPosition(pos)
1281		logging.Debug(fmt.Sprintf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height()))
1282		return true
1283	}
1284
1285	// Attempt immediately with the current allocation.
1286	if setPosition() {
1287		return
1288	}
1289
1290	var frames uint
1291
1292	paned.ConnectMap(func() {
1293		setPosition()
1294	})
1295
1296	paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1297		if guard != nil && !guard.widgetValid {
1298			return false
1299		}
1300
1301		frames++
1302		if setPosition() {
1303			return false
1304		}
1305
1306		if frames >= maxFrames {
1307			alloc := paned.Allocation()
1308			logging.Error(fmt.Sprintf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height()))
1309			return false
1310		}
1311
1312		return true
1313	})
1314
1315	wm.scheduleIdleGuarded(func() bool {
1316		setPosition()
1317		return false
1318	}, guard)
1319}

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