v0.13.0 976176a

workspace_pane_ops.go RawHistoryFolder

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

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