v0.10.0 0f4e9eb

workspace_manager_tree_test.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"testing"
6	"time"
7
8	"github.com/bnema/dumber/pkg/webkit"
9)
10
11// Test helper to create mock pane node
12func newMockPaneNode(id string) *paneNode {
13	container := uintptr(100 + len(id))
14
15	return &paneNode{
16		pane:      &BrowserPane{id: id},
17		container: container,
18		isLeaf:    true,
19	}
20}
21
22// Test tree validation helper
23func validateTreeStructure(t *testing.T, node *paneNode) {
24	t.Helper()
25
26	if node == nil {
27		return
28	}
29
30	if node.isLeaf {
31		// Leaf nodes should not have children
32		if node.left != nil || node.right != nil {
33			t.Errorf("Leaf node has children: left=%v, right=%v", node.left, node.right)
34		}
35		// Leaf nodes must have a pane
36		if node.pane == nil {
37			t.Error("Leaf node missing pane")
38		}
39	} else {
40		// Branch nodes must have both children
41		if node.left == nil || node.right == nil {
42			t.Errorf("Branch node missing children: left=%v, right=%v", node.left, node.right)
43		}
44		// Branch nodes should not have a pane
45		if node.pane != nil {
46			t.Error("Branch node should not have pane")
47		}
48		// Validate children's parent pointers
49		if node.left != nil && node.left.parent != node {
50			t.Error("Left child's parent pointer incorrect")
51		}
52		if node.right != nil && node.right.parent != node {
53			t.Error("Right child's parent pointer incorrect")
54		}
55		// Recursively validate subtrees
56		validateTreeStructure(t, node.left)
57		validateTreeStructure(t, node.right)
58	}
59}
60
61// Count nodes in tree
62func countNodes(node *paneNode) (leaves, branches int) {
63	if node == nil {
64		return 0, 0
65	}
66	if node.isLeaf {
67		return 1, 0
68	}
69	leftLeaves, leftBranches := countNodes(node.left)
70	rightLeaves, rightBranches := countNodes(node.right)
71	return leftLeaves + rightLeaves, leftBranches + rightBranches + 1
72}
73
74func TestPaneNodeTreeInvariants(t *testing.T) {
75	t.Run("Initial root is leaf", func(t *testing.T) {
76		wm := newTestWorkspaceManagerWithMocksForTree(t)
77
78		if !wm.root.isLeaf {
79			t.Error("Initial root should be a leaf")
80		}
81		if wm.root.parent != nil {
82			t.Error("Root should have no parent")
83		}
84		validateTreeStructure(t, wm.root)
85	})
86
87	t.Run("Tree structure after single split", func(t *testing.T) {
88		wm := newTestWorkspaceManagerWithMocksForTree(t)
89		original := wm.root
90
91		// Split right
92		newNode, err := wm.splitNode(original, "right")
93		if err != nil {
94			t.Fatalf("Split failed: %v", err)
95		}
96
97		// Validate new tree structure
98		validateTreeStructure(t, wm.root)
99
100		// Root should now be a branch
101		if wm.root.isLeaf {
102			t.Error("Root should be branch after split")
103		}
104
105		// Original and new nodes should be siblings
106		if wm.root.left != original {
107			t.Error("Original node should be left child")
108		}
109		if wm.root.right != newNode {
110			t.Error("New node should be right child")
111		}
112
113		// Count nodes
114		leaves, branches := countNodes(wm.root)
115		if leaves != 2 {
116			t.Errorf("Expected 2 leaves, got %d", leaves)
117		}
118		if branches != 1 {
119			t.Errorf("Expected 1 branch, got %d", branches)
120		}
121	})
122}
123
124func TestPaneSplitDirections(t *testing.T) {
125	testCases := []struct {
126		name                     string
127		direction                string
128		expectedOrientation      webkit.Orientation
129		expectedOriginalPosition string // "left" or "right" child
130	}{
131		{"SplitRight", "right", webkit.OrientationHorizontal, "left"},
132		{"SplitLeft", "left", webkit.OrientationHorizontal, "right"},
133		{"SplitDown", "down", webkit.OrientationVertical, "left"},
134		{"SplitUp", "up", webkit.OrientationVertical, "right"},
135	}
136
137	for _, tc := range testCases {
138		t.Run(tc.name, func(t *testing.T) {
139			wm := newTestWorkspaceManagerWithMocksForTree(t)
140			original := wm.root
141
142			newNode, err := wm.splitNode(original, tc.direction)
143			if err != nil {
144				t.Fatalf("Split failed: %v", err)
145			}
146
147			parent := original.parent
148			if parent.orientation != tc.expectedOrientation {
149				t.Errorf("Expected orientation %v, got %v",
150					tc.expectedOrientation, parent.orientation)
151			}
152
153			if tc.expectedOriginalPosition == "left" {
154				if parent.left != original {
155					t.Error("Original should be left child")
156				}
157				if parent.right != newNode {
158					t.Error("New node should be right child")
159				}
160			} else {
161				if parent.right != original {
162					t.Error("Original should be right child")
163				}
164				if parent.left != newNode {
165					t.Error("New node should be left child")
166				}
167			}
168
169			validateTreeStructure(t, wm.root)
170		})
171	}
172}
173
174func TestComplexTreeOperations(t *testing.T) {
175	t.Run("Multiple nested splits", func(t *testing.T) {
176		wm := newTestWorkspaceManagerWithMocksForTree(t)
177
178		// Create complex tree: split right, then split the right pane down
179		node1, _ := wm.splitNode(wm.root, "right")
180		node2, _ := wm.splitNode(node1, "down")
181
182		validateTreeStructure(t, wm.root)
183
184		// Count nodes
185		leaves, branches := countNodes(wm.root)
186		if leaves != 3 {
187			t.Errorf("Expected 3 leaves, got %d", leaves)
188		}
189		if branches != 2 {
190			t.Errorf("Expected 2 branches, got %d", branches)
191		}
192
193		// Verify node2 is correctly positioned
194		if node2 == nil {
195			t.Error("node2 should not be nil")
196		}
197	})
198
199	t.Run("Deep nesting", func(t *testing.T) {
200		wm := newTestWorkspaceManagerWithMocksForTree(t)
201
202		// Create a chain of splits
203		current := wm.root
204		for i := 0; i < 5; i++ {
205			newNode, err := wm.splitNode(current, "right")
206			if err != nil {
207				t.Fatalf("Split %d failed: %v", i, err)
208			}
209			current = newNode
210		}
211
212		validateTreeStructure(t, wm.root)
213
214		leaves, branches := countNodes(wm.root)
215		if leaves != 6 {
216			t.Errorf("Expected 6 leaves, got %d", leaves)
217		}
218		if branches != 5 {
219			t.Errorf("Expected 5 branches, got %d", branches)
220		}
221	})
222}
223
224func TestPaneDeletion(t *testing.T) {
225	t.Run("Close leaf promotes sibling", func(t *testing.T) {
226		wm := newTestWorkspaceManagerWithMocksForTree(t)
227		original := wm.root
228
229		// Split and then close the new pane
230		newNode, _ := wm.splitNode(original, "right")
231
232		err := wm.closePane(newNode)
233		if err != nil {
234			t.Fatalf("Close failed: %v", err)
235		}
236
237		// Original should be root again
238		if wm.root != original {
239			t.Error("Original should be promoted to root")
240		}
241		if original.parent != nil {
242			t.Error("Promoted node should have no parent")
243		}
244
245		validateTreeStructure(t, wm.root)
246	})
247
248	t.Run("Close in complex tree", func(t *testing.T) {
249		wm := newTestWorkspaceManagerWithMocksForTree(t)
250
251		// Build tree: root splits right to A and B, then B splits down to C and D
252		A := wm.root
253		B, _ := wm.splitNode(A, "right")
254		C, _ := wm.splitNode(B, "up")
255		// D is the node that B became after split (positioned down)
256		D := B.parent.right
257
258		// Close C - D should be promoted to take the place of C's parent
259		err := wm.closePane(C)
260		if err != nil {
261			t.Fatalf("Close failed: %v", err)
262		}
263
264		validateTreeStructure(t, wm.root)
265
266		// Tree should now have A and D as siblings
267		if wm.root.left != A && wm.root.right != A {
268			t.Error("A should still be child of root")
269		}
270		if wm.root.left != D && wm.root.right != D {
271			t.Error("D should be promoted as child of root")
272		}
273
274		leaves, branches := countNodes(wm.root)
275		if leaves != 2 {
276			t.Errorf("Expected 2 leaves after deletion, got %d", leaves)
277		}
278		if branches != 1 {
279			t.Errorf("Expected 1 branch after deletion, got %d", branches)
280		}
281	})
282}
283
284func TestRootDelegation(t *testing.T) {
285	t.Run("Closing root with siblings delegates correctly", func(t *testing.T) {
286		wm := newTestWorkspaceManagerWithMocksForTree(t)
287
288		// Create structure where root will need delegation
289		originalRoot := wm.root
290		rightNode, _ := wm.splitNode(originalRoot, "right")
291
292		// Now originalRoot and rightNode are siblings under new root
293		// Try to close the left child (originalRoot)
294		err := wm.closePane(originalRoot)
295		if err != nil {
296			t.Fatalf("Close failed: %v", err)
297		}
298
299		// rightNode should now be the root
300		if wm.root != rightNode {
301			t.Error("Right sibling should be promoted to root")
302		}
303		if rightNode.parent != nil {
304			t.Error("New root should have no parent")
305		}
306
307		validateTreeStructure(t, wm.root)
308	})
309}
310
311func TestFindReplacementRoot(t *testing.T) {
312	t.Run("Find replacement in complex tree", func(t *testing.T) {
313		wm := newTestWorkspaceManagerWithMocksForTree(t)
314
315		// Build a complex tree
316		A := wm.root
317		B, _ := wm.splitNode(A, "right")
318		_, _ = wm.splitNode(B, "down")
319
320		// Find replacement excluding A
321		replacement := wm.findReplacementRoot(A)
322
323		if replacement == nil {
324			t.Error("Should find replacement root")
325		}
326		if replacement == A {
327			t.Error("Replacement should not be excluded node")
328		}
329	})
330
331	t.Run("No replacement when only one pane", func(t *testing.T) {
332		wm := newTestWorkspaceManagerWithMocksForTree(t)
333
334		replacement := wm.findReplacementRoot(wm.root)
335		if replacement != nil {
336			t.Error("Should not find replacement when only one pane exists")
337		}
338	})
339}
340
341func TestTreeTraversal(t *testing.T) {
342	t.Run("CollectLeaves gathers all leaf nodes", func(t *testing.T) {
343		wm := newTestWorkspaceManagerWithMocksForTree(t)
344
345		// Create tree with 4 leaves
346		// Keep track of the original root which will become a leaf after split
347		original := wm.root
348		node1, _ := wm.splitNode(original, "right")
349		_, _ = wm.splitNode(node1, "down")
350		_, _ = wm.splitNode(original, "down") // Split the original leaf, not the new root branch
351
352		leaves := wm.collectLeaves()
353
354		if len(leaves) != 4 {
355			t.Errorf("Expected 4 leaves, got %d", len(leaves))
356		}
357
358		// All collected nodes should be leaves
359		for _, leaf := range leaves {
360			if !leaf.isLeaf {
361				t.Error("Non-leaf node in collectLeaves result")
362			}
363		}
364	})
365
366	t.Run("LeftmostLeaf finds correct node", func(t *testing.T) {
367		wm := newTestWorkspaceManagerWithMocksForTree(t)
368
369		original := wm.root
370		right, _ := wm.splitNode(original, "right")
371		_, _ = wm.splitNode(right, "right")
372
373		leftmost := wm.leftmostLeaf(wm.root)
374		if leftmost != original {
375			t.Error("Leftmost should be original node")
376		}
377	})
378
379	t.Run("LeftmostLeaf handles nil", func(t *testing.T) {
380		wm := newTestWorkspaceManagerWithMocksForTree(t)
381
382		leftmost := wm.leftmostLeaf(nil)
383		if leftmost != nil {
384			t.Error("LeftmostLeaf should return nil for nil input")
385		}
386	})
387}
388
389func TestErrorHandling(t *testing.T) {
390	t.Run("Cannot split non-leaf", func(t *testing.T) {
391		wm := newTestWorkspaceManagerWithMocksForTree(t)
392
393		// Create a branch node
394		_, err := wm.splitNode(wm.root, "right")
395		if err != nil {
396			t.Fatalf("Failed to create branch node: %v", err)
397		}
398
399		// Try to split the branch (root is now a branch)
400		_, err = wm.splitNode(wm.root, "down")
401
402		if err == nil {
403			t.Error("Should not allow splitting branch nodes")
404		}
405	})
406
407	t.Run("Cannot close non-leaf", func(t *testing.T) {
408		wm := newTestWorkspaceManagerWithMocksForTree(t)
409
410		_, err := wm.splitNode(wm.root, "right")
411		if err != nil {
412			t.Fatalf("Failed to create branch node: %v", err)
413		}
414
415		// Try to close the branch node
416		err = wm.closePane(wm.root)
417
418		if err == nil {
419			t.Error("Should not allow closing branch nodes")
420		}
421	})
422
423	t.Run("Cannot split nil node", func(t *testing.T) {
424		wm := newTestWorkspaceManagerWithMocksForTree(t)
425
426		_, err := wm.splitNode(nil, "right")
427
428		if err == nil {
429			t.Error("Should not allow splitting nil nodes")
430		}
431	})
432
433	t.Run("Cannot close nil node", func(t *testing.T) {
434		wm := newTestWorkspaceManagerWithMocksForTree(t)
435
436		err := wm.closePane(nil)
437
438		if err == nil {
439			t.Error("Should not allow closing nil nodes")
440		}
441	})
442}
443
444func TestFocusManagement(t *testing.T) {
445	t.Run("Focus transfers after closing active pane", func(t *testing.T) {
446		wm := newTestWorkspaceManagerWithMocksForTree(t)
447
448		original := wm.root
449		newNode, _ := wm.splitNode(original, "right")
450
451		// Set new node as active
452		wm.active = newNode
453
454		// Close the active node
455		err := wm.closePane(newNode)
456		if err != nil {
457			t.Fatalf("Close failed: %v", err)
458		}
459
460		// Focus should transfer to original
461		if wm.active != original {
462			t.Error("Focus should transfer to remaining pane")
463		}
464	})
465
466	t.Run("Focus preserved when closing non-active pane", func(t *testing.T) {
467		wm := newTestWorkspaceManagerWithMocksForTree(t)
468
469		original := wm.root
470		newNode, _ := wm.splitNode(original, "right")
471
472		// Keep original as active
473		wm.active = original
474
475		// Close the non-active node
476		err := wm.closePane(newNode)
477		if err != nil {
478			t.Fatalf("Close failed: %v", err)
479		}
480
481		// Focus should remain on original
482		if wm.active != original {
483			t.Error("Focus should remain on original pane")
484		}
485	})
486}
487
488// TestBugClosePaneWithBranchSibling reproduces the bug where closing a pane
489// whose sibling is a branch (not a leaf) causes "unexpected tree state" error
490func TestBugClosePaneWithBranchSibling(t *testing.T) {
491	t.Run("Closing pane when sibling is branch with children", func(t *testing.T) {
492		wm := newTestWorkspaceManagerWithMocksForTree(t)
493
494		// Create the exact scenario from the bug:
495		// 1. Start with root pane A
496		A := wm.root
497
498		// 2. Split down to create B (A is now top, B is bottom)
499		B, err := wm.splitNode(A, "down")
500		if err != nil {
501			t.Fatalf("Split down failed: %v", err)
502		}
503
504		// After this split:
505		// Root (vertical paned) has A (top) and B (bottom)
506
507		// 3. Split B down again to create C
508		C, err := wm.splitNode(B, "down")
509		if err != nil {
510			t.Fatalf("Split B down failed: %v", err)
511		}
512
513		// After this split, tree structure is:
514		// Root (vertical) -> A (top), SubPaned (vertical, bottom)
515		//                             SubPaned -> B (top), C (bottom)
516		// Total: 3 leaf panes (A, B, C), 2 branch nodes
517
518		// Update app.panes to reflect actual structure
519		wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
520
521		// Verify initial structure
522		validateTreeStructure(t, wm.root)
523		leaves, branches := countNodes(wm.root)
524		if leaves != 3 {
525			t.Errorf("Expected 3 leaves initially, got %d", leaves)
526		}
527		if branches != 2 {
528			t.Errorf("Expected 2 branches initially, got %d", branches)
529		}
530
531		// Now close pane A - this should promote the SubPaned (containing B and C) to root
532		// This is where the bug occurs: parent is root, but 2 panes remain (B and C)
533		// The current code incorrectly thinks this is an error
534		err = wm.closePane(A)
535
536		// THIS IS THE BUG: The current implementation returns an error here
537		// but it shouldn't - it's a valid operation
538		if err != nil {
539			// For now, we expect this to fail with the bug
540			if err.Error() == "unexpected tree state during pane closure" {
541				t.Logf("Bug reproduced: %v", err)
542				t.Logf("This error should not occur - closing pane with branch sibling is valid")
543				// Comment out the return to see what the final state should be
544				// return
545			} else {
546				t.Fatalf("Unexpected error: %v", err)
547			}
548		}
549
550		// What SHOULD happen after successful close:
551		// The SubPaned (containing B and C) should become the new root
552		validateTreeStructure(t, wm.root)
553
554		// Verify final state (what we expect after fix)
555		finalLeaves, finalBranches := countNodes(wm.root)
556		if finalLeaves != 2 {
557			t.Errorf("Expected 2 leaves after close, got %d", finalLeaves)
558		}
559		if finalBranches != 1 {
560			t.Errorf("Expected 1 branch after close, got %d", finalBranches)
561		}
562
563		// The new root should be a branch (the old SubPaned)
564		if wm.root.isLeaf {
565			t.Error("Root should be a branch node after closing A")
566		}
567
568		// B and C should still be in the tree
569		foundB := false
570		foundC := false
571		for _, leaf := range wm.collectLeaves() {
572			if leaf == B {
573				foundB = true
574			}
575			if leaf == C {
576				foundC = true
577			}
578		}
579		if !foundB {
580			t.Error("Pane B should still be in tree")
581		}
582		if !foundC {
583			t.Error("Pane C should still be in tree")
584		}
585	})
586
587	t.Run("Alternative scenario - closing left child when right has grandchildren", func(t *testing.T) {
588		wm := newTestWorkspaceManagerWithMocksForTree(t)
589
590		// Create structure: A | (B / C)
591		// where | is horizontal split and / is vertical split
592		A := wm.root
593		B, err := wm.splitNode(A, "right")
594		if err != nil {
595			t.Fatalf("Split right failed: %v", err)
596		}
597
598		C, err := wm.splitNode(B, "down")
599		if err != nil {
600			t.Fatalf("Split down failed: %v", err)
601		}
602
603		// Update app.panes
604		wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
605
606		// Tree is: Root(h) -> A, Paned(v) -> B, C
607		validateTreeStructure(t, wm.root)
608
609		// Close A - the Paned(v) containing B and C should become root
610		err = wm.closePane(A)
611
612		// Bug should occur here too
613		if err != nil {
614			if err.Error() == "unexpected tree state during pane closure" {
615				t.Logf("Bug reproduced in alternative scenario: %v", err)
616			}
617		}
618
619		// After fix, the vertical paned with B and C should be root
620		validateTreeStructure(t, wm.root)
621	})
622}
623
624func TestEdgeCases(t *testing.T) {
625	t.Run("Closing last pane", func(t *testing.T) {
626		wm := newTestWorkspaceManagerWithMocksForTree(t)
627
628		// Mock the app.panes slice to have only one pane
629		wm.app.panes = []*BrowserPane{wm.root.pane}
630
631		// Closing the last pane should handle gracefully
632		err := wm.closePane(wm.root)
633		if err != nil {
634			t.Logf("Expected behavior: closing last pane returned error: %v", err)
635		}
636	})
637
638	t.Run("Split with invalid direction", func(t *testing.T) {
639		wm := newTestWorkspaceManagerWithMocksForTree(t)
640
641		// Test default behavior with invalid direction
642		newNode, err := wm.splitNode(wm.root, "invalid")
643		if err != nil {
644			t.Fatalf("Split with invalid direction failed: %v", err)
645		}
646
647		// Should default to horizontal orientation
648		if newNode.parent.orientation != webkit.OrientationHorizontal {
649			t.Error("Invalid direction should default to horizontal")
650		}
651	})
652}
653
654func TestInfiniteSiblingsClosingIssue(t *testing.T) {
655	t.Run("Infinite siblings issue reproduction", func(t *testing.T) {
656		wm := newTestWorkspaceManagerWithMocksForTree(t)
657
658		// Reproduce the scenario: Create multiple siblings by repeatedly splitting right
659		original := wm.root
660		rightPane1, err := wm.splitNode(original, "right")
661		if err != nil {
662			t.Fatalf("First split failed: %v", err)
663		}
664
665		// Split the right pane down to create a nested structure
666		downPane, err := wm.splitNode(rightPane1, "down")
667		if err != nil {
668			t.Fatalf("Down split failed: %v", err)
669		}
670
671		// Verify tree structure is correct
672		validateTreeStructure(t, wm.root)
673
674		// Count initial panes
675		initialLeaves, initialBranches := countNodes(wm.root)
676		if initialLeaves != 3 {
677			t.Errorf("Expected 3 leaves after splits, got %d", initialLeaves)
678		}
679		if initialBranches != 2 {
680			t.Errorf("Expected 2 branches after splits, got %d", initialBranches)
681		}
682
683		// Update app.panes to reflect the actual structure
684		wm.app.panes = []*BrowserPane{original.pane, rightPane1.pane, downPane.pane}
685
686		// First close the downPane (the non-direct sibling)
687		err = wm.closePane(downPane)
688		if err != nil {
689			t.Fatalf("Close downPane failed: %v", err)
690		}
691
692		validateTreeStructure(t, wm.root)
693
694		// Now try to close rightPane1 (which should leave only original)
695		// This should cause the promotion of original to root
696		err = wm.closePane(rightPane1)
697		if err != nil {
698			t.Fatalf("Close rightPane1 failed: %v", err)
699		}
700
701		validateTreeStructure(t, wm.root)
702
703		// Final tree should have only original as root
704		if wm.root != original {
705			t.Error("Original should be promoted to root after closing siblings")
706		}
707		if !wm.root.isLeaf {
708			t.Error("Root should be a leaf after closing all siblings")
709		}
710
711		finalLeaves, finalBranches := countNodes(wm.root)
712		if finalLeaves != 1 {
713			t.Errorf("Expected 1 leaf after closing siblings, got %d", finalLeaves)
714		}
715		if finalBranches != 0 {
716			t.Errorf("Expected 0 branches after closing siblings, got %d", finalBranches)
717		}
718	})
719
720	t.Run("Closing direct sibling of root reproduces GTK error", func(t *testing.T) {
721		wm := newTestWorkspaceManagerWithMocksForTree(t)
722
723		// Create a simple two-pane layout (root + sibling)
724		original := wm.root
725		sibling, err := wm.splitNode(original, "right")
726		if err != nil {
727			t.Fatalf("Split failed: %v", err)
728		}
729
730		// Update app.panes
731		wm.app.panes = []*BrowserPane{original.pane, sibling.pane}
732
733		validateTreeStructure(t, wm.root)
734
735		// Now close the sibling - this should promote original back to root
736		// and should NOT cause GTK-CRITICAL errors
737		err = wm.closePane(sibling)
738		if err != nil {
739			t.Fatalf("Close sibling failed: %v", err)
740		}
741
742		validateTreeStructure(t, wm.root)
743
744		// Verify the tree is properly cleaned up
745		if wm.root != original {
746			t.Error("Original should be promoted to root")
747		}
748		if !wm.root.isLeaf {
749			t.Error("Root should be a leaf after closing sibling")
750		}
751		if wm.root.parent != nil {
752			t.Error("Root should have no parent")
753		}
754	})
755
756	t.Run("Complex closing sequence without direct root sibling", func(t *testing.T) {
757		wm := newTestWorkspaceManagerWithMocksForTree(t)
758
759		// Create a more complex tree that can test the issue where
760		// closing requires closing until only the direct sibling of root remains
761
762		// Start with root (A)
763		A := wm.root
764
765		// Split right to create B
766		B, err := wm.splitNode(A, "right")
767		if err != nil {
768			t.Fatalf("Split A->B failed: %v", err)
769		}
770
771		// Split B down to create C (B becomes internal node, C is new leaf)
772		C, err := wm.splitNode(B, "down")
773		if err != nil {
774			t.Fatalf("Split B->C failed: %v", err)
775		}
776		// After this split, the tree structure is:
777		// Root(horizontal) -> A (left), Parent(vertical) (right)
778		//                           -> B (top), C (bottom)
779
780		// Split C right to create D
781		D, err := wm.splitNode(C, "right")
782		if err != nil {
783			t.Fatalf("Split C->D failed: %v", err)
784		}
785
786		// Update app.panes to reflect actual panes
787		wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane, D.pane}
788
789		validateTreeStructure(t, wm.root)
790
791		// Count nodes to verify structure
792		leaves, branches := countNodes(wm.root)
793		if leaves != 4 {
794			t.Errorf("Expected 4 leaves, got %d", leaves)
795		}
796		if branches != 3 {
797			t.Errorf("Expected 3 branches, got %d", branches)
798		}
799
800		// Now start closing panes in a sequence that would eventually
801		// require promoting siblings and potentially trigger the GTK error
802
803		// Close D first
804		err = wm.closePane(D)
805		if err != nil {
806			t.Fatalf("Close D failed: %v", err)
807		}
808		validateTreeStructure(t, wm.root)
809
810		// Close C next
811		err = wm.closePane(C)
812		if err != nil {
813			t.Fatalf("Close C failed: %v", err)
814		}
815		validateTreeStructure(t, wm.root)
816
817		// Close B next - this should leave only A and trigger the root sibling issue
818		err = wm.closePane(B)
819		if err != nil {
820			t.Fatalf("Close B failed: %v", err)
821		}
822		validateTreeStructure(t, wm.root)
823
824		// Verify final state
825		if wm.root != A {
826			t.Error("A should be the final root")
827		}
828		if !wm.root.isLeaf {
829			t.Error("Final root should be a leaf")
830		}
831
832		finalLeaves, finalBranches := countNodes(wm.root)
833		if finalLeaves != 1 {
834			t.Errorf("Expected 1 final leaf, got %d", finalLeaves)
835		}
836		if finalBranches != 0 {
837			t.Errorf("Expected 0 final branches, got %d", finalBranches)
838		}
839	})
840}
841
842func TestViewToNodeMapping(t *testing.T) {
843	t.Run("ViewToNode map updates correctly", func(t *testing.T) {
844		wm := newTestWorkspaceManagerWithMocksForTree(t)
845
846		originalNode := wm.root
847		originalView := wm.root.pane.webView
848
849		// Split should add new mapping
850		newNode, _ := wm.splitNode(wm.root, "right")
851		newView := newNode.pane.webView
852
853		// Check mapping exists for new view
854		if mapped, ok := wm.viewToNode[newView]; !ok || mapped != newNode {
855			t.Error("New webview should be mapped to new node")
856		}
857
858		// Original mapping should still exist - note that wm.root may have changed after split
859		if mapped, ok := wm.viewToNode[originalView]; !ok || mapped != originalNode {
860			t.Error("Original webview mapping should be preserved")
861		}
862
863		// Close should remove mapping
864		err := wm.closePane(newNode)
865		if err != nil {
866			t.Fatalf("Failed to close pane: %v", err)
867		}
868
869		if _, ok := wm.viewToNode[newView]; ok {
870			t.Error("Closed pane's webview should be removed from mapping")
871		}
872	})
873}
874
875// Helper to create WorkspaceManager with minimal mocked dependencies
876func newTestWorkspaceManagerWithMocksForTree(t *testing.T) *WorkspaceManager {
877	t.Helper()
878	webkit.ResetWidgetStubsForTesting()
879
880	// Create a real WebView for testing (stub implementation)
881	rootView, err := webkit.NewWebView(&webkit.Config{})
882	if err != nil {
883		t.Fatalf("failed to create root webview: %v", err)
884	}
885
886	rootPane := &BrowserPane{
887		webView: rootView,
888		id:      "root",
889	}
890
891	// Create mock window shortcut handler
892	mockWindowShortcutHandler := &mockWindowShortcutHandler{}
893
894	app := &BrowserApp{
895		panes:                 []*BrowserPane{rootPane},
896		activePane:            rootPane,
897		webView:               rootView,
898		windowShortcutHandler: mockWindowShortcutHandler,
899	}
900
901	wm := &WorkspaceManager{
902		app:          app,
903		window:       rootView.Window(),
904		viewToNode:   make(map[*webkit.WebView]*paneNode),
905		lastSplitMsg: make(map[*webkit.WebView]time.Time),
906		lastExitMsg:  make(map[*webkit.WebView]time.Time),
907	}
908
909	// Set up mock factories
910	wm.createWebViewFn = func() (*webkit.WebView, error) {
911		return webkit.NewWebView(&webkit.Config{})
912	}
913
914	wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
915		return &BrowserPane{
916			webView: view,
917			id:      fmt.Sprintf("pane-%p", view),
918		}, nil
919	}
920
921	// Initialize root
922	root := &paneNode{
923		pane:      rootPane,
924		container: rootView.RootWidget(),
925		isLeaf:    true,
926	}
927	wm.root = root
928	wm.active = root
929	wm.mainPane = root
930	wm.viewToNode[rootView] = root
931
932	return wm
933}

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