v0.11.0 dc50343

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

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