v0.12.0 d035193

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// splitNode splits a target pane into two panes in the specified direction
303// splitNode splits a target pane into two panes in the specified direction.
304// If existingPane is provided, it will be used instead of creating a new WebView.
305// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
306func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
307	if target == nil || !target.isLeaf || target.pane == nil {
308		return nil, errors.New("split target must be a leaf pane")
309	}
310
311	// IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
312	// not the individual pane or the stack itself. We create a new split at the stack's parent level.
313	originalTarget := target
314	var stackContainer *paneNode
315	if target.parent != nil && target.parent.isStacked {
316		// Target is in a stack - we'll create the split around the entire stack
317		stackContainer = target.parent
318		log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
319	}
320
321	// Determine what we're actually splitting
322	var splitTarget *paneNode
323	var splitTargetContainer gtk.Widgetter
324
325	if stackContainer != nil {
326		// Case: splitting from inside a stack - we split around the entire stack
327		splitTarget = stackContainer
328		splitTargetContainer = stackContainer.container
329		log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
330	} else {
331		// Case: normal split from a simple pane
332		splitTarget = target
333		splitTargetContainer = target.container
334		log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
335	}
336
337	// Use provided pane or create new one
338	var newPane *BrowserPane
339
340	if existingPane != nil {
341		// Reuse existing pane (for popup insertion)
342		newPane = existingPane
343		log.Printf("[workspace] Using existing pane for split: %p", newPane)
344	} else {
345		// Create new WebView and pane (for normal splits)
346		newView, err := wm.createWebView()
347		if err != nil {
348			return nil, err
349		}
350
351		newPane, err = wm.createPane(newView)
352		if err != nil {
353			return nil, err
354		}
355		log.Printf("[workspace] Created new pane for split: %p", newPane)
356	}
357
358	if handler := newPane.MessageHandler(); handler != nil {
359		handler.SetWorkspaceObserver(wm)
360	}
361
362	newContainer := newPane.webView.RootWidget()
363	if newContainer == nil {
364		return nil, errors.New("new pane missing container")
365	}
366	webkit.WidgetSetHExpand(newContainer, true)
367	webkit.WidgetSetVExpand(newContainer, true)
368
369	// Use the determined split target container
370	if splitTargetContainer == nil {
371		return nil, errors.New("split target missing container")
372	}
373
374	// Only apply GTK operations if this is a simple widget (leaf pane)
375	// For stacked containers, we need to be more careful
376	if splitTarget.isLeaf {
377		if splitTargetContainer != nil {
378			webkit.WidgetSetHExpand(splitTargetContainer, true)
379			webkit.WidgetSetVExpand(splitTargetContainer, true)
380		}
381	} else if splitTarget.isStacked {
382		// Stack containers need the same setup as regular panes for proper splitting
383		if splitTargetContainer != nil {
384			webkit.WidgetSetHExpand(splitTargetContainer, true)
385			webkit.WidgetSetVExpand(splitTargetContainer, true)
386			log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer)
387		}
388	}
389
390	orientation, existingFirst := mapDirection(direction)
391	log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
392		direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil)
393
394	paned := gtk.NewPaned(orientation)
395	if paned == nil {
396		return nil, errors.New("failed to create GtkPaned")
397	}
398	paned.SetHExpand(true)
399	paned.SetVExpand(true)
400
401	// Allow both children to resize; avoids shrinking existing pane to zero during root splits
402	paned.SetResizeStartChild(true)
403	paned.SetResizeEndChild(true)
404
405	newLeaf := &paneNode{
406		pane:   newPane,
407		isLeaf: true,
408	}
409	wm.initializePaneWidgets(newLeaf, newContainer)
410
411	split := &paneNode{
412		parent:      splitTarget.parent,
413		orientation: orientation,
414		isLeaf:      false,
415	}
416	wm.initializePaneWidgets(split, paned)
417
418	parent := split.parent
419
420	// Clear CSS classes before reparenting to avoid GTK bloom filter corruption
421	// NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
422	// The focus state machine will apply the active class to the new pane via SetActivePane()
423	if splitTargetContainer != nil {
424		// Remove active border class if present (prevents GTK bloom filter corruption during unparent)
425		if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
426			webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
427			log.Printf("[workspace] removed active class from split target (will transfer to new pane)")
428		}
429	}
430
431	// Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
432	wm.safelyDetachControllersBeforeReparent(splitTarget)
433
434	// For non-root splits, unparent from parent paned
435	if parent != nil && parent.container != nil {
436		// Split target has a parent paned - remove it (automatically unparents in GTK4)
437		log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container)
438		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
439			if parent.left == splitTarget {
440				parentPaned.SetStartChild(nil)
441			} else if parent.right == splitTarget {
442				parentPaned.SetEndChild(nil)
443			}
444			// GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
445			webkit.WidgetQueueAllocate(parent.container)
446		}
447	}
448	// NOTE: For root splits (parent == nil), we DON'T unparent here.
449	// We'll unparent immediately before adding to paned below to minimize orphan time.
450
451	// Set up the tree structure first
452	if existingFirst {
453		split.left = splitTarget
454		split.right = newLeaf
455	} else {
456		split.left = newLeaf
457		split.right = splitTarget
458	}
459
460	splitTarget.parent = split
461	newLeaf.parent = split
462
463	// Update tree root/parent references
464	if parent == nil {
465		wm.root = split
466	} else {
467		if parent.left == splitTarget {
468			parent.left = split
469		} else if parent.right == splitTarget {
470			parent.right = split
471		}
472	}
473
474	// GTK4 handles widget operations automatically - no need to force redraw
475
476	// Handle root split: attach paned to window FIRST, then let GTK handle reparenting
477	if parent == nil {
478		// ROOT SPLIT: Attach empty paned to window, then add children
479		// GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild
480		if wm.window != nil {
481			// Clear any existing window child (the splitTargetContainer)
482			wm.window.SetChild(nil)
483
484			// Attach the new paned to window FIRST
485			wm.window.SetChild(paned)
486			log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
487		}
488
489		// Now add children to the paned - GTK will handle reparenting automatically
490		if existingFirst {
491			paned.SetStartChild(splitTargetContainer)
492			paned.SetEndChild(newContainer)
493			log.Printf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer)
494		} else {
495			paned.SetStartChild(newContainer)
496			paned.SetEndChild(splitTargetContainer)
497			log.Printf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer)
498		}
499	} else {
500		// NON-ROOT SPLIT: Standard approach
501
502		// Add both containers to the new paned
503		if existingFirst {
504			if splitTargetContainer != nil {
505				paned.SetStartChild(splitTargetContainer)
506				paned.SetEndChild(newContainer)
507				log.Printf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer)
508			}
509		} else {
510			if splitTargetContainer != nil {
511				paned.SetStartChild(newContainer)
512				paned.SetEndChild(splitTargetContainer)
513				log.Printf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer)
514			}
515		}
516
517		// Attach the new paned to its parent
518		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
519			if parent.left == split {
520				parentPaned.SetStartChild(paned)
521			} else if parent.right == split {
522				parentPaned.SetEndChild(paned)
523			}
524			webkit.WidgetQueueAllocate(parent.container)
525			paned.QueueAllocate()
526			log.Printf("[workspace] paned inserted into parent=%p", parent.container)
527		}
528	}
529
530	paned.Show()
531
532	// Synchronize the divider once the paned has a meaningful allocation.
533	wm.syncPanedDivider(paned, orientation, split)
534
535	// GTK4 automatically handles widget rendering after reparenting - no manual updates needed
536
537	wm.viewToNode[newPane.webView] = newLeaf
538	wm.ensureHover(newLeaf)
539
540	// Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
541	// The originalTarget is still in the stack and should not interfere with the new split pane
542	if originalTarget == target {
543		// Only ensure hover on target if it's a regular (non-stack) split
544		if !target.pendingHoverReattach {
545			wm.ensureHover(target)
546		}
547	}
548
549	// Only add to app.panes if this is a new pane (not an existing popup pane)
550	if existingPane == nil {
551		wm.app.panes = append(wm.app.panes, newPane)
552	} else {
553		// For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
554		log.Printf("[workspace] Skipping app.panes append for existing popup pane (already added)")
555	}
556
557	if newPane.zoomController != nil {
558		newPane.zoomController.ApplyInitialZoom()
559	}
560
561	// Update CSS classes for all panes now that we have multiple panes
562
563	reattachTargets := []*paneNode{splitTarget}
564	if splitTarget != nil && splitTarget.isStacked {
565		reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
566	}
567	guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
568	wm.scheduleIdleGuarded(func() bool {
569		if newLeaf == nil || !newLeaf.widgetValid {
570			return false
571		}
572		if newContainer != nil {
573			webkit.WidgetShow(newContainer)
574		}
575
576		// CRITICAL: For root splits, also show the split target container after reparenting
577		// When the split target is reparented from window → paned, GTK doesn't preserve visibility
578		if parent == nil && splitTargetContainer != nil {
579			webkit.WidgetShow(splitTargetContainer)
580			webkit.WidgetQueueAllocate(splitTargetContainer)
581			webkit.WidgetQueueDraw(splitTargetContainer)
582		}
583
584		// Ensure the underlying WebView widget is visible after reparenting
585		if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
586			if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
587				webkit.WidgetShow(webViewWidget)
588				webkit.WidgetQueueAllocate(webViewWidget)
589				webkit.WidgetQueueDraw(webViewWidget)
590			}
591		}
592
593		// Attach GTK focus controller to new pane
594		if wm.focusStateMachine != nil {
595			wm.focusStateMachine.attachGTKController(newLeaf)
596		}
597
598		for _, candidate := range reattachTargets {
599			if candidate == nil || !candidate.widgetValid {
600				continue
601			}
602			if candidate.pendingHoverReattach {
603				wm.ensureHover(candidate)
604				if candidate.hoverToken != nil {
605					candidate.pendingHoverReattach = false
606				}
607			}
608			if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
609				wm.focusStateMachine.attachGTKController(candidate)
610				if candidate.focusControllerToken != 0 {
611					candidate.pendingFocusReattach = false
612				}
613			}
614		}
615		wm.SetActivePane(newLeaf, SourceSplit)
616		return false
617	}, guardNodes...)
618
619	return newLeaf, nil
620}
621
622// Helper functions for the simplified closePane implementation
623
624// getSibling returns the sibling node for a given node
625func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
626	if node.parent == nil {
627		return nil
628	}
629	if node.parent.left == node {
630		return node.parent.right
631	}
632	return node.parent.left
633}
634
635// nextCleanupGeneration returns the next cleanup generation counter
636func (wm *WorkspaceManager) nextCleanupGeneration() uint {
637	wm.cleanupCounter++
638	return wm.cleanupCounter
639}
640
641// promoteSibling promotes a sibling node to replace its parent in the tree
642func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
643	if grand == nil {
644		wm.root = sibling
645		sibling.parent = nil
646		return
647	}
648	sibling.parent = grand
649	if grand.left == parent {
650		grand.left = sibling
651	} else {
652		grand.right = sibling
653	}
654}
655
656// swapContainers updates GTK widget hierarchy for promoted siblings
657func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
658	if grand == nil {
659		// When TreeRebalancer is enabled, skip GTK attachment here as it will be
660		// handled by the rebalancer's promotion transaction to avoid double-attachment
661		if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
662			wm.attachRoot(sibling)
663		}
664		return
665	}
666	// Clear CSS classes before reparenting to avoid GTK bloom filter corruption
667	if sibling.container != nil {
668		if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
669			webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
670		}
671	}
672
673	if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
674		// GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
675		// Focus was already cleared before unparenting closed pane
676		if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
677			log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
678		}
679
680		if grand.left == sibling {
681			grandPaned.SetStartChild(sibling.container)
682			log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container)
683		} else {
684			grandPaned.SetEndChild(sibling.container)
685			log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container)
686		}
687		webkit.WidgetQueueAllocate(grand.container)
688	}
689}
690
691// cascadePromotion handles the case where a paned node has only one child
692// This happens when closing a pane leaves the grandparent with only one child
693func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
694	if singleChildPaned == nil {
695		return
696	}
697
698	// Find the single child
699	var onlyChild *paneNode
700	if singleChildPaned.left != nil && singleChildPaned.right == nil {
701		onlyChild = singleChildPaned.left
702	} else if singleChildPaned.right != nil && singleChildPaned.left == nil {
703		onlyChild = singleChildPaned.right
704	} else {
705		// Either no children or two children - nothing to cascade
706		return
707	}
708
709	log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild)
710
711	greatGrandparent := singleChildPaned.parent
712
713	// Update tree structure
714	onlyChild.parent = greatGrandparent
715	if greatGrandparent == nil {
716		// Single child becomes new root
717		wm.root = onlyChild
718		log.Printf("[workspace] Only child %p promoted to root", onlyChild)
719
720		// Attach to window
721		if onlyChild.container != nil {
722			wm.attachRoot(onlyChild)
723		}
724
725		// Cleanup the now-orphaned paned
726		wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
727	} else {
728		// Replace paned in great-grandparent
729		if greatGrandparent.left == singleChildPaned {
730			greatGrandparent.left = onlyChild
731		} else {
732			greatGrandparent.right = onlyChild
733		}
734
735		log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
736
737		// Reparent widget in GTK
738		if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
739			// GTK4 PanedSet*Child automatically handles unparenting
740			if greatGrandparent.left == onlyChild {
741				greatGrandPaned.SetStartChild(onlyChild.container)
742			} else {
743				greatGrandPaned.SetEndChild(onlyChild.container)
744			}
745
746			webkit.WidgetSetHExpand(onlyChild.container, true)
747			webkit.WidgetSetVExpand(onlyChild.container, true)
748			webkit.WidgetQueueAllocate(onlyChild.container)
749			webkit.WidgetQueueAllocate(greatGrandparent.container)
750			wm.scheduleIdleGuarded(func() bool {
751				if onlyChild == nil || !onlyChild.widgetValid {
752					return false
753				}
754				if onlyChild.container != nil {
755					webkit.WidgetShow(onlyChild.container)
756					webkit.WidgetQueueResize(onlyChild.container)
757					webkit.WidgetQueueDraw(onlyChild.container)
758				}
759				return false
760			}, onlyChild)
761		}
762
763		// Cleanup the now-orphaned paned
764		wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
765
766		// Check if great-grandparent now also has only one child (recursive case)
767		if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
768			log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent)
769			wm.cascadePromotion(greatGrandparent)
770		}
771	}
772}
773
774// attachRoot attaches a node as the new window root
775func (wm *WorkspaceManager) attachRoot(root *paneNode) {
776	if root == nil || root.container == nil || wm.window == nil {
777		return
778	}
779
780	// Note: In gotk4, reference counting is handled automatically by Go's GC
781	// No need for manual Ref()/Unref() calls
782
783	// Detach from previous container (paned, stack, etc.) before replacing the window child.
784	// While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
785	if parent := webkit.WidgetGetParent(root.container); parent != nil {
786		log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent)
787		webkit.WidgetUnparent(root.container)
788		// Note: In gotk4, the Go object reference remains valid after unparent
789		// Only the GTK parent relationship is cleared
790	}
791
792	// Clear any existing window child to avoid GTK warnings when swapping roots.
793	wm.window.SetChild(nil)
794
795	if root.container != nil {
796		webkit.WidgetSetHExpand(root.container, true)
797		webkit.WidgetSetVExpand(root.container, true)
798	}
799
800	// GTK4 will automatically unparent root.container from its previous parent when we attach it here
801	wm.window.SetChild(root.container)
802	if root.container != nil {
803		webkit.WidgetQueueAllocate(root.container)
804		webkit.WidgetShow(root.container)
805		webkit.WidgetQueueResize(root.container)
806		webkit.WidgetQueueDraw(root.container)
807	}
808
809	wm.scheduleIdleGuarded(func() bool {
810		if root == nil || !root.widgetValid {
811			return false
812		}
813		if root.container != nil {
814			webkit.WidgetQueueResize(root.container)
815			webkit.WidgetQueueDraw(root.container)
816		}
817		return false
818	}, root)
819
820	// Verify attachment succeeded
821	if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
822		log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container)
823	} else {
824		log.Printf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent)
825	}
826}
827
828// cleanupPane safely cleans up a pane node with generation tracking
829func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
830	if node == nil {
831		return
832	}
833	if !node.widgetValid {
834		return
835	}
836
837	if wm.focusStateMachine != nil {
838		wm.focusStateMachine.InvalidateActivePane(node)
839	}
840
841	wm.cancelIdleHandles(node)
842
843	node.widgetValid = false
844	node.cleanupGeneration = generation
845
846	if node.pane != nil {
847		pane := node.pane
848		pane.Cleanup()
849		pane.CleanupFromWorkspace(wm)
850		if pane.webView != nil {
851			log.Printf("[workspace] releasing WebView reference: %p", pane.webView)
852			pane.webView = nil
853		}
854		node.pane = nil
855	}
856
857	if node.container != nil {
858		// Container widget will be destroyed by GTK when unparented
859		node.container = nil
860	}
861
862	node.pendingIdleHandles = nil
863	node.parent = nil
864	node.left = nil
865	node.right = nil
866}
867
868// decommissionParent cleans up a parent node after promotion
869func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
870	if parent == nil {
871		return
872	}
873
874	// For branch nodes (paneds), destroy the widget
875	// Children have already been unparented during promotion/close, no need to clear them
876	if !parent.isLeaf && parent.container != nil {
877		// In gotk4, we don't need to manually destroy - GTK handles this when unparented
878		// Just mark it for cleanup
879		log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container)
880	}
881
882	wm.cleanupPane(parent, generation)
883}
884
885// promoteNewRoot handles root replacement when closing the root pane
886func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
887	candidate := wm.findReplacementRoot(oldRoot)
888	if candidate == nil {
889		return wm.cleanupAndExit(oldRoot)
890	}
891
892	sibling := wm.getSibling(candidate)
893	if sibling != nil {
894		wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
895	}
896
897	candidate.parent = nil
898	wm.root = candidate
899
900	wm.attachRoot(candidate)
901
902	wm.cleanupPane(oldRoot, ctx.Generation())
903	return candidate, nil
904}
905
906// cleanupAndExit handles the final pane cleanup and application exit
907func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
908	log.Printf("[workspace] closing final pane; exiting browser")
909	wm.cleanupPane(node, wm.nextCleanupGeneration())
910	wm.detachHover(node)
911	
912	// Gracefully quit the GTK main loop
913	webkit.QuitMainLoop()
914	return nil, nil
915}
916
917// setFocusToLeaf sets focus to the leftmost leaf of a node
918func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
919	focusTarget := wm.leftmostLeaf(node)
920	if focusTarget != nil {
921		wm.SetActivePane(focusTarget, SourceClose)
922	}
923}
924
925// closePane closes a specific pane and handles tree restructuring (simplified implementation)
926func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
927	ctx := wm.beginClose(node)
928	defer ctx.finish()
929
930	// STEP 1: Basic validation (quick fail)
931	if ctx.err != nil {
932		return nil, ctx.err
933	}
934
935	// STEP 2: Handle stacked panes via compatibility shim
936	if node.parent != nil && node.parent.isStacked {
937		return wm.closeStackedPaneCompat(node)
938	}
939
940	// Ensure we detach hover/focus controllers before the widget hierarchy changes.
941	wm.detachHover(node)
942	wm.detachFocus(node)
943
944	// STEP 3: Handle trivial exit cases
945	// Don't exit if closing a popup - popups aren't counted in app.panes
946	if ctx.remaining == 1 && !node.isPopup {
947		return wm.cleanupAndExit(node)
948	}
949	if node == wm.root {
950		return wm.promoteNewRoot(ctx, node)
951	}
952
953	// STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
954	parent := node.parent
955	sibling := wm.getSibling(node)
956	grandparent := parent.parent
957
958	// Clear grandparent's focus child BEFORE unparenting closed pane
959	// GTK propagates focus events upward, so we must clear grandparent focus first
960	if grandparent != nil && grandparent.container != nil {
961		webkit.WidgetSetFocusChild(grandparent.container, nil)
962	}
963
964	// Unparent the closed pane's container from the paned BEFORE promoting sibling
965	if node.container != nil {
966		if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
967			if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
968				if parent.left == node {
969					parentPaned.SetStartChild(nil)
970				} else if parent.right == node {
971					parentPaned.SetEndChild(nil)
972				}
973				log.Printf("[workspace] unparented closed pane container: %p", node.container)
974			}
975		}
976	}
977
978	// STEP 5: Unparent sibling from parent paned BEFORE promotion
979	// The sibling needs to be detached from the parent paned before we can attach it elsewhere
980	if sibling.container != nil && parent.container != nil {
981		if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
982			if parent.left == sibling {
983				parentPaned.SetStartChild(nil)
984			} else if parent.right == sibling {
985				parentPaned.SetEndChild(nil)
986			}
987			log.Printf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container)
988		}
989	}
990
991	// STEP 6: Promote sibling in-place (tree structure)
992	wm.promoteSibling(grandparent, parent, sibling)
993
994	// STEP 7: GTK widget reparenting
995	// For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
996	// For window: we already manually unparented above, so now we can safely attach
997	wm.swapContainers(grandparent, sibling)
998
999	// Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1000	if sibling != nil && sibling.container != nil {
1001		wm.scheduleIdleGuarded(func() bool {
1002			if sibling == nil || !sibling.widgetValid {
1003				return false
1004			}
1005			if sibling.container != nil {
1006				webkit.WidgetShow(sibling.container)
1007				webkit.WidgetQueueResize(sibling.container)
1008				webkit.WidgetQueueDraw(sibling.container)
1009			}
1010			return false
1011		}, sibling)
1012	}
1013
1014	// STEP 8: Check if grandparent now has only one child (cascade promotion needed)
1015	if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1016		// Grandparent has exactly one child - it should be promoted too
1017		log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1018		wm.cascadePromotion(grandparent)
1019	}
1020
1021	// STEP 9: Cleanup & focus
1022	wm.cleanupPane(node, ctx.Generation())
1023	wm.decommissionParent(parent, ctx.Generation())
1024	if sibling != nil {
1025		wm.scheduleIdleGuarded(func() bool {
1026			if sibling == nil || !sibling.widgetValid {
1027				return false
1028			}
1029			wm.setFocusToLeaf(sibling)
1030			return false
1031		}, sibling)
1032	}
1033
1034	return sibling, nil
1035}
1036
1037// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1038func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1039	stack := node.parent
1040
1041	// Find index
1042	index := -1
1043	for i, pane := range stack.stackedPanes {
1044		if pane == node {
1045			index = i
1046			break
1047		}
1048	}
1049
1050	if index == -1 {
1051		return nil, errors.New("pane not in stack")
1052	}
1053
1054	// Remove from array
1055	stack.stackedPanes = append(
1056		stack.stackedPanes[:index],
1057		stack.stackedPanes[index+1:]...,
1058	)
1059
1060	// If only one pane left, unstack it
1061	if len(stack.stackedPanes) == 1 {
1062		remaining := stack.stackedPanes[0]
1063
1064		// Replace stack with remaining pane in tree
1065		remaining.parent = stack.parent
1066		if stack.parent != nil {
1067			if stack.parent.left == stack {
1068				stack.parent.left = remaining
1069			} else {
1070				stack.parent.right = remaining
1071			}
1072
1073			// Update GTK widgets
1074			if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
1075				if stack.parent.left == remaining {
1076					parentPaned.SetStartChild(remaining.container)
1077				} else {
1078					parentPaned.SetEndChild(remaining.container)
1079				}
1080			}
1081		} else {
1082			// Stack was root
1083			wm.root = remaining
1084			if remaining.container != nil {
1085				wm.window.SetChild(remaining.container)
1086			}
1087		}
1088
1089		// Cleanup stack container
1090		wm.cleanupPane(stack, wm.nextCleanupGeneration())
1091
1092		return remaining, nil
1093	}
1094
1095	// Update active index if needed
1096	if stack.activeStackIndex >= len(stack.stackedPanes) {
1097		stack.activeStackIndex = len(stack.stackedPanes) - 1
1098	}
1099
1100	// Remove widget from GTK box
1101	if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1102		stackBox.Remove(node.container)
1103	}
1104
1105	// Show new active pane
1106	if stack.activeStackIndex >= 0 {
1107		active := stack.stackedPanes[stack.activeStackIndex]
1108		if active.container != nil {
1109			webkit.WidgetShow(active.container)
1110		}
1111	}
1112
1113	// Cleanup closed pane
1114	wm.cleanupPane(node, wm.nextCleanupGeneration())
1115
1116	return stack, nil
1117}
1118
1119// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1120// the divider to an even split. GTK often reports near-zero allocations for a few frames
1121// after insertion, so we retry on map/tick/idle until the size stabilizes.
1122func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1123	if wm == nil || paned == nil {
1124		return
1125	}
1126
1127	const (
1128		minDimension = 32
1129		maxFrames    = 120
1130	)
1131
1132	setPosition := func() bool {
1133		if guard != nil && !guard.widgetValid {
1134			return true
1135		}
1136
1137		alloc := paned.Allocation()
1138		dimension := alloc.Width()
1139		if orientation == gtk.OrientationVertical {
1140			dimension = alloc.Height()
1141		}
1142
1143		if dimension < minDimension {
1144			return false
1145		}
1146
1147		pos := dimension / 2
1148		if pos <= 0 {
1149			return false
1150		}
1151
1152		paned.SetPosition(pos)
1153		log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1154		return true
1155	}
1156
1157	// Attempt immediately with the current allocation.
1158	if setPosition() {
1159		return
1160	}
1161
1162	var frames uint
1163
1164	paned.ConnectMap(func() {
1165		setPosition()
1166	})
1167
1168	paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1169		if guard != nil && !guard.widgetValid {
1170			return false
1171		}
1172
1173		frames++
1174		if setPosition() {
1175			return false
1176		}
1177
1178		if frames >= maxFrames {
1179			alloc := paned.Allocation()
1180			log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1181			return false
1182		}
1183
1184		return true
1185	})
1186
1187	wm.scheduleIdleGuarded(func() bool {
1188		setPosition()
1189		return false
1190	}, guard)
1191}

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