v0.14.0 76d1892

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

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