detached 6f9d17f

feat(layout): add position-aware InsertPaneAfter for stacked views

Implement InsertPaneAfter method to insert new panes immediately after the
current active pane, rather than always appending to the end. Update
StackedPaneManager to use this new method. Includes comprehensive tests for
position-aware insertion behavior with various edge cases.
commit
6f9d17f4fadd86cb0fe39cc7bc84685ffc3b4e57
author
brice · · 2025-12-17 22:25 UTC
committer
brice · · 2025-12-17 20:36 UTC
parent
786d68a
signature
unsigned
3 files · +384 -3
M internal/ui/component/stacked_pane_manager.go +7 -3
@@ -60,19 +60,23 @@ func (spm *StackedPaneManager) AddPaneToStack(
6060 widget.Unparent()
6161 }
6262
63- // Add to the stack (this makes it active automatically)
63+ // Add to the stack after the current active pane (not at end)
6464 if title == "" {
6565 title = "Untitled"
6666 }
6767
68+ // Get current active index to insert after it
69+ currentActiveIndex := stackedView.ActiveIndex()
70+
6871 log.Debug().
6972 Str("active_pane", string(activePaneID)).
7073 Str("new_pane", string(newPaneView.PaneID())).
7174 Str("title", title).
75+ Int("current_active_index", currentActiveIndex).
7276 Int("stack_size_before", stackedView.Count()).
73- Msg("StackedPaneManager: adding pane to stack")
77+ Msg("StackedPaneManager: inserting pane after active position")
7478
75- stackedView.AddPane(ctx, title, "", widget)
79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)
7680
7781 // Register the new pane in the TreeRenderer's tracking
7882 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
@@ -60,19 +60,23 @@ func (spm *StackedPaneManager) AddPaneToStack(
60 widget.Unparent()60 widget.Unparent()
61 }61 }
62 62
63- // Add to the stack (this makes it active automatically)63+ // Add to the stack after the current active pane (not at end)
64 if title == "" {64 if title == "" {
65 title = "Untitled"65 title = "Untitled"
66 }66 }
67 67
68+ // Get current active index to insert after it
69+ currentActiveIndex := stackedView.ActiveIndex()
70+
68 log.Debug().71 log.Debug().
69 Str("active_pane", string(activePaneID)).72 Str("active_pane", string(activePaneID)).
70 Str("new_pane", string(newPaneView.PaneID())).73 Str("new_pane", string(newPaneView.PaneID())).
71 Str("title", title).74 Str("title", title).
75+ Int("current_active_index", currentActiveIndex).
72 Int("stack_size_before", stackedView.Count()).76 Int("stack_size_before", stackedView.Count()).
73- Msg("StackedPaneManager: adding pane to stack")77+ Msg("StackedPaneManager: inserting pane after active position")
74 78
75- stackedView.AddPane(ctx, title, "", widget)79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)
76 80
77 // Register the new pane in the TreeRenderer's tracking81 // Register the new pane in the TreeRenderer's tracking
78 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)82 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
M internal/ui/layout/stacked.go +128 -0
@@ -141,6 +141,134 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
141141 return index
142142 }
143143
144+// InsertPaneAfter inserts a new pane after the specified index position.
145+// Use afterIndex=-1 to insert at the beginning.
146+// The new pane becomes active (visible).
147+// Returns the index where the pane was inserted.
148+func (sv *StackedView) InsertPaneAfter(
149+ ctx context.Context, afterIndex int, title, faviconIconName string, container Widget,
150+) int {
151+ log := logging.FromContext(ctx)
152+ sv.mu.Lock()
153+ defer sv.mu.Unlock()
154+
155+ // Validate afterIndex - clamp to valid range
156+ if afterIndex < -1 {
157+ afterIndex = -1
158+ }
159+ if afterIndex >= len(sv.panes) {
160+ afterIndex = len(sv.panes) - 1
161+ }
162+ insertIndex := afterIndex + 1
163+
164+ log.Debug().
165+ Str("title", title).
166+ Int("after_index", afterIndex).
167+ Int("insert_index", insertIndex).
168+ Int("current_count", len(sv.panes)).
169+ Msg("StackedView.InsertPaneAfter called")
170+
171+ // Create title bar - must not expand vertically
172+ titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
173+ titleBar.AddCssClass("stacked-pane-titlebar")
174+ titleBar.SetVexpand(false)
175+
176+ // Create favicon image
177+ favicon := sv.factory.NewImage()
178+ if faviconIconName != "" {
179+ favicon.SetFromIconName(faviconIconName)
180+ } else {
181+ favicon.SetFromIconName("web-browser-symbolic")
182+ }
183+ favicon.SetPixelSize(16)
184+ titleBar.Append(favicon)
185+
186+ // Create title label
187+ label := sv.factory.NewLabel(title)
188+ label.SetEllipsize(EllipsizeEnd)
189+ label.SetMaxWidthChars(30)
190+ label.SetHexpand(true)
191+ label.SetXalign(0.0)
192+ titleBar.Append(label)
193+
194+ // Make title bar clickable - ensure it doesn't expand vertically
195+ titleButton := sv.factory.NewButton()
196+ titleButton.SetChild(titleBar)
197+ titleButton.AddCssClass("stacked-pane-title-button")
198+ titleButton.SetFocusOnClick(false)
199+ titleButton.SetVexpand(false)
200+ titleButton.SetHexpand(true)
201+
202+ pane := &stackedPane{
203+ titleBar: titleBar,
204+ container: container,
205+ title: title,
206+ favicon: favicon,
207+ label: label,
208+ isActive: false,
209+ }
210+
211+ // Insert into slice at correct position
212+ sv.panes = append(sv.panes, nil)
213+ copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
214+ sv.panes[insertIndex] = pane
215+
216+ // Connect click handler - use captured index
217+ capturedIndex := insertIndex
218+ titleButton.ConnectClicked(func() {
219+ sv.mu.RLock()
220+ callback := sv.onActivate
221+ sv.mu.RUnlock()
222+
223+ if callback != nil {
224+ callback(capturedIndex)
225+ }
226+ })
227+
228+ // Insert widgets at correct position in GTK box
229+ if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
230+ // Insert after the previous pane's container
231+ prevPane := sv.panes[insertIndex-1]
232+ if prevPane.container != nil {
233+ sv.box.InsertChildAfter(titleButton, prevPane.container)
234+ if container != nil {
235+ sv.box.InsertChildAfter(container, titleButton)
236+ }
237+ } else {
238+ // No container, insert after title bar's parent (the button)
239+ prevTitleParent := prevPane.titleBar.GetParent()
240+ if prevTitleParent != nil {
241+ sv.box.InsertChildAfter(titleButton, prevTitleParent)
242+ if container != nil {
243+ sv.box.InsertChildAfter(container, titleButton)
244+ }
245+ } else {
246+ // Fallback to append
247+ sv.box.Append(titleButton)
248+ if container != nil {
249+ sv.box.Append(container)
250+ }
251+ }
252+ }
253+ } else {
254+ // Insert at beginning
255+ if container != nil {
256+ sv.box.Prepend(container)
257+ }
258+ sv.box.Prepend(titleButton)
259+ }
260+
261+ // Set this pane as active
262+ sv.setActiveInternal(ctx, insertIndex)
263+
264+ log.Debug().
265+ Int("insert_index", insertIndex).
266+ Int("new_count", len(sv.panes)).
267+ Msg("StackedView.InsertPaneAfter completed")
268+
269+ return insertIndex
270+}
271+
144272 // RemovePane removes a pane from the stack by index.
145273 // Returns an error if trying to remove the last pane.
146274 func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
@@ -141,6 +141,134 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
141 return index141 return index
142 }142 }
143 143
144+// InsertPaneAfter inserts a new pane after the specified index position.
145+// Use afterIndex=-1 to insert at the beginning.
146+// The new pane becomes active (visible).
147+// Returns the index where the pane was inserted.
148+func (sv *StackedView) InsertPaneAfter(
149+ ctx context.Context, afterIndex int, title, faviconIconName string, container Widget,
150+) int {
151+ log := logging.FromContext(ctx)
152+ sv.mu.Lock()
153+ defer sv.mu.Unlock()
154+
155+ // Validate afterIndex - clamp to valid range
156+ if afterIndex < -1 {
157+ afterIndex = -1
158+ }
159+ if afterIndex >= len(sv.panes) {
160+ afterIndex = len(sv.panes) - 1
161+ }
162+ insertIndex := afterIndex + 1
163+
164+ log.Debug().
165+ Str("title", title).
166+ Int("after_index", afterIndex).
167+ Int("insert_index", insertIndex).
168+ Int("current_count", len(sv.panes)).
169+ Msg("StackedView.InsertPaneAfter called")
170+
171+ // Create title bar - must not expand vertically
172+ titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
173+ titleBar.AddCssClass("stacked-pane-titlebar")
174+ titleBar.SetVexpand(false)
175+
176+ // Create favicon image
177+ favicon := sv.factory.NewImage()
178+ if faviconIconName != "" {
179+ favicon.SetFromIconName(faviconIconName)
180+ } else {
181+ favicon.SetFromIconName("web-browser-symbolic")
182+ }
183+ favicon.SetPixelSize(16)
184+ titleBar.Append(favicon)
185+
186+ // Create title label
187+ label := sv.factory.NewLabel(title)
188+ label.SetEllipsize(EllipsizeEnd)
189+ label.SetMaxWidthChars(30)
190+ label.SetHexpand(true)
191+ label.SetXalign(0.0)
192+ titleBar.Append(label)
193+
194+ // Make title bar clickable - ensure it doesn't expand vertically
195+ titleButton := sv.factory.NewButton()
196+ titleButton.SetChild(titleBar)
197+ titleButton.AddCssClass("stacked-pane-title-button")
198+ titleButton.SetFocusOnClick(false)
199+ titleButton.SetVexpand(false)
200+ titleButton.SetHexpand(true)
201+
202+ pane := &stackedPane{
203+ titleBar: titleBar,
204+ container: container,
205+ title: title,
206+ favicon: favicon,
207+ label: label,
208+ isActive: false,
209+ }
210+
211+ // Insert into slice at correct position
212+ sv.panes = append(sv.panes, nil)
213+ copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
214+ sv.panes[insertIndex] = pane
215+
216+ // Connect click handler - use captured index
217+ capturedIndex := insertIndex
218+ titleButton.ConnectClicked(func() {
219+ sv.mu.RLock()
220+ callback := sv.onActivate
221+ sv.mu.RUnlock()
222+
223+ if callback != nil {
224+ callback(capturedIndex)
225+ }
226+ })
227+
228+ // Insert widgets at correct position in GTK box
229+ if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
230+ // Insert after the previous pane's container
231+ prevPane := sv.panes[insertIndex-1]
232+ if prevPane.container != nil {
233+ sv.box.InsertChildAfter(titleButton, prevPane.container)
234+ if container != nil {
235+ sv.box.InsertChildAfter(container, titleButton)
236+ }
237+ } else {
238+ // No container, insert after title bar's parent (the button)
239+ prevTitleParent := prevPane.titleBar.GetParent()
240+ if prevTitleParent != nil {
241+ sv.box.InsertChildAfter(titleButton, prevTitleParent)
242+ if container != nil {
243+ sv.box.InsertChildAfter(container, titleButton)
244+ }
245+ } else {
246+ // Fallback to append
247+ sv.box.Append(titleButton)
248+ if container != nil {
249+ sv.box.Append(container)
250+ }
251+ }
252+ }
253+ } else {
254+ // Insert at beginning
255+ if container != nil {
256+ sv.box.Prepend(container)
257+ }
258+ sv.box.Prepend(titleButton)
259+ }
260+
261+ // Set this pane as active
262+ sv.setActiveInternal(ctx, insertIndex)
263+
264+ log.Debug().
265+ Int("insert_index", insertIndex).
266+ Int("new_count", len(sv.panes)).
267+ Msg("StackedView.InsertPaneAfter completed")
268+
269+ return insertIndex
270+}
271+
144 // RemovePane removes a pane from the stack by index.272 // RemovePane removes a pane from the stack by index.
145 // Returns an error if trying to remove the last pane.273 // Returns an error if trying to remove the last pane.
146 func (sv *StackedView) RemovePane(ctx context.Context, index int) error {274 func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
M internal/ui/layout/stacked_test.go +249 -0
@@ -504,3 +504,252 @@ func TestSetOnActivate_Callback(t *testing.T) {
504504 // simulating a button click. The test verifies the method doesn't panic.
505505 // Integration tests would verify the actual callback invocation.
506506 }
507+
508+// setupInsertPaneMocks creates mocks needed for InsertPaneAfter with position-aware insertion.
509+// Returns only the titleBar and container mocks that are needed by test assertions.
510+func setupInsertPaneMocks(
511+ t *testing.T,
512+ mockFactory *mocks.MockWidgetFactory,
513+ mockBox *mocks.MockBoxWidget,
514+ siblingContainer layout.Widget,
515+) (*mocks.MockBoxWidget, *mocks.MockWidget) {
516+ mockTitleBar := mocks.NewMockBoxWidget(t)
517+ mockFavicon := mocks.NewMockImageWidget(t)
518+ mockLabel := mocks.NewMockLabelWidget(t)
519+ mockButton := mocks.NewMockButtonWidget(t)
520+ mockContainer := mocks.NewMockWidget(t)
521+
522+ // Title bar creation
523+ mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
524+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
525+ mockTitleBar.EXPECT().SetVexpand(false).Once()
526+
527+ // Favicon
528+ mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
529+ mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
530+ mockFavicon.EXPECT().SetPixelSize(16).Once()
531+ mockTitleBar.EXPECT().Append(mockFavicon).Once()
532+
533+ // Label
534+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
535+ mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
536+ mockLabel.EXPECT().SetMaxWidthChars(30).Once()
537+ mockLabel.EXPECT().SetHexpand(true).Once()
538+ mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
539+ mockTitleBar.EXPECT().Append(mockLabel).Once()
540+
541+ // Button wrapping title bar
542+ mockFactory.EXPECT().NewButton().Return(mockButton).Once()
543+ mockButton.EXPECT().SetChild(mockTitleBar).Once()
544+ mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
545+ mockButton.EXPECT().SetFocusOnClick(false).Once()
546+ mockButton.EXPECT().SetVexpand(false).Once()
547+ mockButton.EXPECT().SetHexpand(true).Once()
548+ mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
549+
550+ // Position-aware insertion using InsertChildAfter
551+ if siblingContainer != nil {
552+ mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once()
553+ mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once()
554+ } else {
555+ // Insert at beginning using Prepend
556+ mockBox.EXPECT().Prepend(mockContainer).Once()
557+ mockBox.EXPECT().Prepend(mockButton).Once()
558+ }
559+
560+ return mockTitleBar, mockContainer
561+}
562+
563+func TestInsertPaneAfter_AtBeginning(t *testing.T) {
564+ // Arrange
565+ ctx := context.Background()
566+ mockFactory, mockBox := setupMockFactory(t)
567+
568+ // First pane - appended normally
569+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
570+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
571+ mockContainer1.EXPECT().SetVisible(true).Once()
572+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
573+
574+ sv := layout.NewStackedView(mockFactory)
575+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
576+
577+ // Second pane - inserted at beginning (afterIndex=-1)
578+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
579+
580+ // Visibility updates
581+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
582+ mockContainer1.EXPECT().SetVisible(false).Once()
583+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
584+
585+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
586+ mockContainer2.EXPECT().SetVisible(true).Once()
587+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
588+
589+ // Act
590+ index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2)
591+
592+ // Assert
593+ assert.Equal(t, 0, index)
594+ assert.Equal(t, 2, sv.Count())
595+ assert.Equal(t, 0, sv.ActiveIndex())
596+}
597+
598+func TestInsertPaneAfter_InMiddle(t *testing.T) {
599+ // Arrange
600+ ctx := context.Background()
601+ mockFactory, mockBox := setupMockFactory(t)
602+
603+ // Setup 2 initial panes
604+ containers := make([]*mocks.MockWidget, 2)
605+ titleBars := make([]*mocks.MockBoxWidget, 2)
606+
607+ for i := 0; i < 2; i++ {
608+ titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
609+ titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
610+ containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
611+ titleBars[i].EXPECT().AddCssClass("active").Maybe()
612+ titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
613+ }
614+
615+ sv := layout.NewStackedView(mockFactory)
616+ sv.AddPane(ctx, "Page 1", "", containers[0])
617+ sv.AddPane(ctx, "Page 2", "", containers[1])
618+
619+ // Set active to first pane
620+ require.NoError(t, sv.SetActive(ctx, 0))
621+
622+ // Insert new pane after index 0 (should become index 1)
623+ mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])
624+ mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe()
625+ mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()
626+ mockTitleBar3.EXPECT().AddCssClass("active").Maybe()
627+ mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
628+
629+ // Act
630+ index := sv.InsertPaneAfter(ctx, 0, "Page 1.5", "", mockContainer3)
631+
632+ // Assert
633+ assert.Equal(t, 1, index)
634+ assert.Equal(t, 3, sv.Count())
635+ assert.Equal(t, 1, sv.ActiveIndex()) // New pane becomes active
636+}
637+
638+func TestInsertPaneAfter_AtEnd(t *testing.T) {
639+ // Arrange
640+ ctx := context.Background()
641+ mockFactory, mockBox := setupMockFactory(t)
642+
643+ // First pane
644+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
645+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
646+ mockContainer1.EXPECT().SetVisible(true).Once()
647+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
648+
649+ sv := layout.NewStackedView(mockFactory)
650+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
651+
652+ // Insert after last pane (afterIndex=0, becomes index 1)
653+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
654+
655+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
656+ mockContainer1.EXPECT().SetVisible(false).Once()
657+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
658+
659+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
660+ mockContainer2.EXPECT().SetVisible(true).Once()
661+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
662+
663+ // Act
664+ index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2)
665+
666+ // Assert
667+ assert.Equal(t, 1, index)
668+ assert.Equal(t, 2, sv.Count())
669+ assert.Equal(t, 1, sv.ActiveIndex())
670+}
671+
672+func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
673+ // Arrange
674+ ctx := context.Background()
675+ mockFactory, mockBox := setupMockFactory(t)
676+
677+ // Setup 3 initial panes: A, B, C
678+ containers := make([]*mocks.MockWidget, 3)
679+ titleBars := make([]*mocks.MockBoxWidget, 3)
680+
681+ for i := 0; i < 3; i++ {
682+ titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
683+ titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
684+ containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
685+ titleBars[i].EXPECT().AddCssClass("active").Maybe()
686+ titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
687+ }
688+
689+ sv := layout.NewStackedView(mockFactory)
690+ sv.AddPane(ctx, "A", "", containers[0]) // index 0
691+ sv.AddPane(ctx, "B", "", containers[1]) // index 1
692+ sv.AddPane(ctx, "C", "", containers[2]) // index 2
693+
694+ // Navigate to pane B (index 1)
695+ require.NoError(t, sv.SetActive(ctx, 1))
696+
697+ // Insert D after B (should be at index 2, C moves to index 3)
698+ mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])
699+ mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe()
700+ mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()
701+ mockTitleBar4.EXPECT().AddCssClass("active").Maybe()
702+ mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
703+
704+ // Act
705+ index := sv.InsertPaneAfter(ctx, 1, "D", "", mockContainer4)
706+
707+ // Assert: Order should be A(0), B(1), D(2), C(3)
708+ assert.Equal(t, 2, index)
709+ assert.Equal(t, 4, sv.Count())
710+ assert.Equal(t, 2, sv.ActiveIndex()) // D is now active
711+
712+ // Verify we can still get containers at expected indices
713+ containerA, _ := sv.GetContainer(0)
714+ containerB, _ := sv.GetContainer(1)
715+ containerD, _ := sv.GetContainer(2)
716+ containerC, _ := sv.GetContainer(3)
717+
718+ assert.Equal(t, containers[0], containerA)
719+ assert.Equal(t, containers[1], containerB)
720+ assert.Equal(t, mockContainer4, containerD)
721+ assert.Equal(t, containers[2], containerC)
722+}
723+
724+func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
725+ // Arrange
726+ ctx := context.Background()
727+ mockFactory, mockBox := setupMockFactory(t)
728+
729+ // First pane
730+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
731+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
732+ mockContainer1.EXPECT().SetVisible(true).Once()
733+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
734+
735+ sv := layout.NewStackedView(mockFactory)
736+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
737+
738+ // Try to insert at invalid index (100) - should clamp to end
739+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
740+
741+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
742+ mockContainer1.EXPECT().SetVisible(false).Once()
743+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
744+
745+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
746+ mockContainer2.EXPECT().SetVisible(true).Once()
747+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
748+
749+ // Act - afterIndex=100 should be clamped to 0 (last valid index)
750+ index := sv.InsertPaneAfter(ctx, 100, "Page 2", "", mockContainer2)
751+
752+ // Assert
753+ assert.Equal(t, 1, index)
754+ assert.Equal(t, 2, sv.Count())
755+}
@@ -504,3 +504,252 @@ func TestSetOnActivate_Callback(t *testing.T) {
504 // simulating a button click. The test verifies the method doesn't panic.504 // simulating a button click. The test verifies the method doesn't panic.
505 // Integration tests would verify the actual callback invocation.505 // Integration tests would verify the actual callback invocation.
506 }506 }
507+
508+// setupInsertPaneMocks creates mocks needed for InsertPaneAfter with position-aware insertion.
509+// Returns only the titleBar and container mocks that are needed by test assertions.
510+func setupInsertPaneMocks(
511+ t *testing.T,
512+ mockFactory *mocks.MockWidgetFactory,
513+ mockBox *mocks.MockBoxWidget,
514+ siblingContainer layout.Widget,
515+) (*mocks.MockBoxWidget, *mocks.MockWidget) {
516+ mockTitleBar := mocks.NewMockBoxWidget(t)
517+ mockFavicon := mocks.NewMockImageWidget(t)
518+ mockLabel := mocks.NewMockLabelWidget(t)
519+ mockButton := mocks.NewMockButtonWidget(t)
520+ mockContainer := mocks.NewMockWidget(t)
521+
522+ // Title bar creation
523+ mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
524+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
525+ mockTitleBar.EXPECT().SetVexpand(false).Once()
526+
527+ // Favicon
528+ mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
529+ mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
530+ mockFavicon.EXPECT().SetPixelSize(16).Once()
531+ mockTitleBar.EXPECT().Append(mockFavicon).Once()
532+
533+ // Label
534+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
535+ mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
536+ mockLabel.EXPECT().SetMaxWidthChars(30).Once()
537+ mockLabel.EXPECT().SetHexpand(true).Once()
538+ mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
539+ mockTitleBar.EXPECT().Append(mockLabel).Once()
540+
541+ // Button wrapping title bar
542+ mockFactory.EXPECT().NewButton().Return(mockButton).Once()
543+ mockButton.EXPECT().SetChild(mockTitleBar).Once()
544+ mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
545+ mockButton.EXPECT().SetFocusOnClick(false).Once()
546+ mockButton.EXPECT().SetVexpand(false).Once()
547+ mockButton.EXPECT().SetHexpand(true).Once()
548+ mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
549+
550+ // Position-aware insertion using InsertChildAfter
551+ if siblingContainer != nil {
552+ mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once()
553+ mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once()
554+ } else {
555+ // Insert at beginning using Prepend
556+ mockBox.EXPECT().Prepend(mockContainer).Once()
557+ mockBox.EXPECT().Prepend(mockButton).Once()
558+ }
559+
560+ return mockTitleBar, mockContainer
561+}
562+
563+func TestInsertPaneAfter_AtBeginning(t *testing.T) {
564+ // Arrange
565+ ctx := context.Background()
566+ mockFactory, mockBox := setupMockFactory(t)
567+
568+ // First pane - appended normally
569+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
570+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
571+ mockContainer1.EXPECT().SetVisible(true).Once()
572+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
573+
574+ sv := layout.NewStackedView(mockFactory)
575+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
576+
577+ // Second pane - inserted at beginning (afterIndex=-1)
578+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
579+
580+ // Visibility updates
581+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
582+ mockContainer1.EXPECT().SetVisible(false).Once()
583+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
584+
585+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
586+ mockContainer2.EXPECT().SetVisible(true).Once()
587+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
588+
589+ // Act
590+ index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2)
591+
592+ // Assert
593+ assert.Equal(t, 0, index)
594+ assert.Equal(t, 2, sv.Count())
595+ assert.Equal(t, 0, sv.ActiveIndex())
596+}
597+
598+func TestInsertPaneAfter_InMiddle(t *testing.T) {
599+ // Arrange
600+ ctx := context.Background()
601+ mockFactory, mockBox := setupMockFactory(t)
602+
603+ // Setup 2 initial panes
604+ containers := make([]*mocks.MockWidget, 2)
605+ titleBars := make([]*mocks.MockBoxWidget, 2)
606+
607+ for i := 0; i < 2; i++ {
608+ titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
609+ titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
610+ containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
611+ titleBars[i].EXPECT().AddCssClass("active").Maybe()
612+ titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
613+ }
614+
615+ sv := layout.NewStackedView(mockFactory)
616+ sv.AddPane(ctx, "Page 1", "", containers[0])
617+ sv.AddPane(ctx, "Page 2", "", containers[1])
618+
619+ // Set active to first pane
620+ require.NoError(t, sv.SetActive(ctx, 0))
621+
622+ // Insert new pane after index 0 (should become index 1)
623+ mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])
624+ mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe()
625+ mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()
626+ mockTitleBar3.EXPECT().AddCssClass("active").Maybe()
627+ mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
628+
629+ // Act
630+ index := sv.InsertPaneAfter(ctx, 0, "Page 1.5", "", mockContainer3)
631+
632+ // Assert
633+ assert.Equal(t, 1, index)
634+ assert.Equal(t, 3, sv.Count())
635+ assert.Equal(t, 1, sv.ActiveIndex()) // New pane becomes active
636+}
637+
638+func TestInsertPaneAfter_AtEnd(t *testing.T) {
639+ // Arrange
640+ ctx := context.Background()
641+ mockFactory, mockBox := setupMockFactory(t)
642+
643+ // First pane
644+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
645+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
646+ mockContainer1.EXPECT().SetVisible(true).Once()
647+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
648+
649+ sv := layout.NewStackedView(mockFactory)
650+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
651+
652+ // Insert after last pane (afterIndex=0, becomes index 1)
653+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
654+
655+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
656+ mockContainer1.EXPECT().SetVisible(false).Once()
657+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
658+
659+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
660+ mockContainer2.EXPECT().SetVisible(true).Once()
661+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
662+
663+ // Act
664+ index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2)
665+
666+ // Assert
667+ assert.Equal(t, 1, index)
668+ assert.Equal(t, 2, sv.Count())
669+ assert.Equal(t, 1, sv.ActiveIndex())
670+}
671+
672+func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
673+ // Arrange
674+ ctx := context.Background()
675+ mockFactory, mockBox := setupMockFactory(t)
676+
677+ // Setup 3 initial panes: A, B, C
678+ containers := make([]*mocks.MockWidget, 3)
679+ titleBars := make([]*mocks.MockBoxWidget, 3)
680+
681+ for i := 0; i < 3; i++ {
682+ titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
683+ titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
684+ containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
685+ titleBars[i].EXPECT().AddCssClass("active").Maybe()
686+ titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
687+ }
688+
689+ sv := layout.NewStackedView(mockFactory)
690+ sv.AddPane(ctx, "A", "", containers[0]) // index 0
691+ sv.AddPane(ctx, "B", "", containers[1]) // index 1
692+ sv.AddPane(ctx, "C", "", containers[2]) // index 2
693+
694+ // Navigate to pane B (index 1)
695+ require.NoError(t, sv.SetActive(ctx, 1))
696+
697+ // Insert D after B (should be at index 2, C moves to index 3)
698+ mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])
699+ mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe()
700+ mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()
701+ mockTitleBar4.EXPECT().AddCssClass("active").Maybe()
702+ mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
703+
704+ // Act
705+ index := sv.InsertPaneAfter(ctx, 1, "D", "", mockContainer4)
706+
707+ // Assert: Order should be A(0), B(1), D(2), C(3)
708+ assert.Equal(t, 2, index)
709+ assert.Equal(t, 4, sv.Count())
710+ assert.Equal(t, 2, sv.ActiveIndex()) // D is now active
711+
712+ // Verify we can still get containers at expected indices
713+ containerA, _ := sv.GetContainer(0)
714+ containerB, _ := sv.GetContainer(1)
715+ containerD, _ := sv.GetContainer(2)
716+ containerC, _ := sv.GetContainer(3)
717+
718+ assert.Equal(t, containers[0], containerA)
719+ assert.Equal(t, containers[1], containerB)
720+ assert.Equal(t, mockContainer4, containerD)
721+ assert.Equal(t, containers[2], containerC)
722+}
723+
724+func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
725+ // Arrange
726+ ctx := context.Background()
727+ mockFactory, mockBox := setupMockFactory(t)
728+
729+ // First pane
730+ mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
731+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
732+ mockContainer1.EXPECT().SetVisible(true).Once()
733+ mockTitleBar1.EXPECT().AddCssClass("active").Once()
734+
735+ sv := layout.NewStackedView(mockFactory)
736+ sv.AddPane(ctx, "Page 1", "", mockContainer1)
737+
738+ // Try to insert at invalid index (100) - should clamp to end
739+ mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
740+
741+ mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
742+ mockContainer1.EXPECT().SetVisible(false).Once()
743+ mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
744+
745+ mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
746+ mockContainer2.EXPECT().SetVisible(true).Once()
747+ mockTitleBar2.EXPECT().AddCssClass("active").Once()
748+
749+ // Act - afterIndex=100 should be clamped to 0 (last valid index)
750+ index := sv.InsertPaneAfter(ctx, 100, "Page 2", "", mockContainer2)
751+
752+ // Assert
753+ assert.Equal(t, 1, index)
754+ assert.Equal(t, 2, sv.Count())
755+}

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