v0.23.2 bfd5eb4

stacked_test.go RawHistoryFolder

1package layout_test
2
3import (
4	"context"
5	"testing"
6
7	"github.com/stretchr/testify/assert"
8	"github.com/stretchr/testify/mock"
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/dumber/internal/ui/layout"
12	"github.com/bnema/dumber/internal/ui/layout/mocks"
13)
14
15// setupMockFactory creates a factory that returns configured mocks for stacked view tests.
16func setupMockFactory(t *testing.T) (*mocks.MockWidgetFactory, *mocks.MockBoxWidget) {
17	mockFactory := mocks.NewMockWidgetFactory(t)
18	mockBox := mocks.NewMockBoxWidget(t)
19
20	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
21	mockBox.EXPECT().SetHexpand(true).Once()
22	mockBox.EXPECT().SetVexpand(true).Once()
23
24	return mockFactory, mockBox
25}
26
27// setupPaneMocks creates mocks needed for AddPane
28func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) (
29	*mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockButtonWidget, *mocks.MockWidget,
30) {
31	mockTitleBar := mocks.NewMockBoxWidget(t)
32	mockFavicon := mocks.NewMockImageWidget(t)
33	mockLabel := mocks.NewMockLabelWidget(t)
34	mockButton := mocks.NewMockButtonWidget(t)
35	mockContainer := mocks.NewMockWidget(t)
36
37	// Title bar creation
38	mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
39	mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
40	mockTitleBar.EXPECT().SetVexpand(false).Once()
41
42	// Favicon
43	mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
44	mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
45	mockFavicon.EXPECT().SetPixelSize(16).Once()
46	mockTitleBar.EXPECT().Append(mockFavicon).Once()
47
48	// Label
49	mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
50	mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
51	mockLabel.EXPECT().SetMaxWidthChars(30).Once()
52	mockLabel.EXPECT().SetHexpand(true).Once()
53	mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
54	mockTitleBar.EXPECT().Append(mockLabel).Once()
55
56	// Button wrapping title bar
57	mockFactory.EXPECT().NewButton().Return(mockButton).Once()
58	mockButton.EXPECT().SetChild(mockTitleBar).Once()
59	mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
60	mockButton.EXPECT().SetFocusOnClick(false).Once()
61	mockButton.EXPECT().SetVexpand(false).Once()
62	mockButton.EXPECT().SetHexpand(true).Once()
63	mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
64
65	// Adding to main box
66	mockBox.EXPECT().Append(mockButton).Once()
67	mockBox.EXPECT().Append(mockContainer).Once()
68
69	return mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer
70}
71
72func TestNewStackedView_EmptyStack(t *testing.T) {
73	// Arrange
74	mockFactory, _ := setupMockFactory(t)
75
76	// Act
77	sv := layout.NewStackedView(mockFactory)
78
79	// Assert
80	require.NotNil(t, sv)
81	assert.Equal(t, 0, sv.Count())
82	assert.Equal(t, -1, sv.ActiveIndex())
83}
84
85func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
86	// Arrange
87	ctx := context.Background()
88	mockFactory, mockBox := setupMockFactory(t)
89	mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
90	_ = mockFavicon
91	_ = mockLabel
92	_ = mockButton
93
94	// Visibility updates for active pane
95	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
96	mockContainer.EXPECT().SetVisible(true).Once()
97	mockTitleBar.EXPECT().AddCssClass("active").Once()
98
99	sv := layout.NewStackedView(mockFactory)
100
101	// Act
102	index := sv.AddPane(ctx, "pane-1", "Test Page", "web-browser-symbolic", mockContainer)
103
104	// Assert
105	assert.Equal(t, 0, index)
106	assert.Equal(t, 1, sv.Count())
107	assert.Equal(t, 0, sv.ActiveIndex())
108}
109
110func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
111	// Arrange
112	ctx := context.Background()
113	mockFactory, mockBox := setupMockFactory(t)
114
115	// First pane
116	mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
117	_ = mockFavicon1
118	_ = mockLabel1
119	_ = mockButton1
120	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
121	mockContainer1.EXPECT().SetVisible(true).Once()
122	mockTitleBar1.EXPECT().AddCssClass("active").Once()
123
124	// Second pane - first pane becomes inactive
125	mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox)
126	_ = mockFavicon2
127	_ = mockLabel2
128	_ = mockButton2
129
130	// When second pane is added, update visibility for both
131	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
132	mockContainer1.EXPECT().SetVisible(false).Once()
133	mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
134
135	mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
136	mockContainer2.EXPECT().SetVisible(true).Once()
137	mockTitleBar2.EXPECT().AddCssClass("active").Once()
138
139	sv := layout.NewStackedView(mockFactory)
140
141	// Act
142	sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
143	index := sv.AddPane(ctx, "pane-2", "Page 2", "", mockContainer2)
144
145	// Assert
146	assert.Equal(t, 1, index)
147	assert.Equal(t, 2, sv.Count())
148	assert.Equal(t, 1, sv.ActiveIndex())
149}
150
151func TestRemovePane_MiddlePane(t *testing.T) {
152	// Arrange
153	ctx := context.Background()
154	mockFactory, mockBox := setupMockFactory(t)
155
156	// Setup 3 panes (simplified - just track the key behaviors)
157	containers := make([]*mocks.MockWidget, 3)
158	titleBars := make([]*mocks.MockBoxWidget, 3)
159	buttons := make([]*mocks.MockButtonWidget, 3)
160
161	for i := 0; i < 3; i++ {
162		var btn *mocks.MockButtonWidget
163		titleBars[i], _, _, btn, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
164		buttons[i] = btn
165	}
166
167	// Allow any visibility calls during setup and removal
168	for i := 0; i < 3; i++ {
169		titleBars[i].EXPECT().GetParent().Return(buttons[i]).Maybe()
170		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
171		titleBars[i].EXPECT().AddCssClass("active").Maybe()
172		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
173		// Button visibility changes when panes are activated/deactivated
174		buttons[i].EXPECT().SetVisible(mock.Anything).Maybe()
175	}
176
177	sv := layout.NewStackedView(mockFactory)
178	sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
179	sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
180	sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])
181
182	// Remove middle pane (index 1)
183	// The parent of titleBar is the button widget
184	mockBox.EXPECT().Remove(buttons[1]).Once()
185	mockBox.EXPECT().Remove(containers[1]).Once()
186
187	// Act
188	err := sv.RemovePane(ctx, 1)
189
190	// Assert
191	require.NoError(t, err)
192	assert.Equal(t, 2, sv.Count())
193}
194
195func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
196	// Arrange
197	ctx := context.Background()
198	mockFactory, mockBox := setupMockFactory(t)
199	mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
200	_ = mockFavicon
201	_ = mockLabel
202	_ = mockButton
203
204	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
205	mockContainer.EXPECT().SetVisible(true).Once()
206	mockTitleBar.EXPECT().AddCssClass("active").Once()
207
208	sv := layout.NewStackedView(mockFactory)
209	sv.AddPane(ctx, "pane-1", "Only Page", "", mockContainer)
210
211	// Act
212	err := sv.RemovePane(ctx, 0)
213
214	// Assert
215	require.ErrorIs(t, err, layout.ErrCannotRemoveLastPane)
216	assert.Equal(t, 1, sv.Count())
217}
218
219func TestRemovePane_EmptyStack_ReturnsError(t *testing.T) {
220	// Arrange
221	ctx := context.Background()
222	mockFactory, _ := setupMockFactory(t)
223	sv := layout.NewStackedView(mockFactory)
224
225	// Act
226	err := sv.RemovePane(ctx, 0)
227
228	// Assert
229	require.ErrorIs(t, err, layout.ErrStackEmpty)
230}
231
232func TestRemovePane_IndexOutOfBounds(t *testing.T) {
233	// Arrange
234	ctx := context.Background()
235	mockFactory, mockBox := setupMockFactory(t)
236	mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
237	_ = mockFavicon
238	_ = mockLabel
239	_ = mockButton
240
241	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
242	mockContainer.EXPECT().SetVisible(true).Once()
243	mockTitleBar.EXPECT().AddCssClass("active").Once()
244
245	sv := layout.NewStackedView(mockFactory)
246	sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
247
248	// Act
249	err := sv.RemovePane(ctx, 5)
250
251	// Assert
252	require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
253}
254
255func TestSetActive_ValidIndex(t *testing.T) {
256	// Arrange
257	ctx := context.Background()
258	mockFactory, mockBox := setupMockFactory(t)
259
260	containers := make([]*mocks.MockWidget, 2)
261	titleBars := make([]*mocks.MockBoxWidget, 2)
262
263	for i := 0; i < 2; i++ {
264		titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
265		_ = mockFavicon
266		_ = mockOverlay
267		_ = mockSpinner
268
269		titleBars[i] = titleBar
270		containers[i] = container
271
272		titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
273		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
274		titleBars[i].EXPECT().AddCssClass("active").Maybe()
275		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
276	}
277
278	sv := layout.NewStackedView(mockFactory)
279	sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
280	sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
281
282	// Act - switch back to first pane
283	err := sv.SetActive(ctx, 0)
284
285	// Assert
286	require.NoError(t, err)
287	assert.Equal(t, 0, sv.ActiveIndex())
288}
289
290func TestSetActive_OutOfBounds(t *testing.T) {
291	// Arrange
292	ctx := context.Background()
293	mockFactory, mockBox := setupMockFactory(t)
294	mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
295	_ = mockFavicon
296	_ = mockLabel
297	_ = mockButton
298
299	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
300	mockContainer.EXPECT().SetVisible(true).Once()
301	mockTitleBar.EXPECT().AddCssClass("active").Once()
302
303	sv := layout.NewStackedView(mockFactory)
304	sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
305
306	// Act
307	err := sv.SetActive(ctx, 5)
308
309	// Assert
310	require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
311}
312
313func TestSetActive_EmptyStack(t *testing.T) {
314	// Arrange
315	ctx := context.Background()
316	mockFactory, _ := setupMockFactory(t)
317	sv := layout.NewStackedView(mockFactory)
318
319	// Act
320	err := sv.SetActive(ctx, 0)
321
322	// Assert
323	require.ErrorIs(t, err, layout.ErrStackEmpty)
324}
325
326func TestUpdateTitle(t *testing.T) {
327	// Arrange
328	ctx := context.Background()
329	mockFactory, mockBox := setupMockFactory(t)
330	mockTitleBar, _, mockLabel, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
331
332	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
333	mockContainer.EXPECT().SetVisible(true).Once()
334	mockTitleBar.EXPECT().AddCssClass("active").Once()
335
336	sv := layout.NewStackedView(mockFactory)
337	sv.AddPane(ctx, "pane-1", "Old Title", "", mockContainer)
338
339	mockLabel.EXPECT().SetText("New Title").Once()
340
341	// Act
342	err := sv.UpdateTitle(0, "New Title")
343
344	// Assert
345	require.NoError(t, err)
346}
347
348func TestUpdateTitle_InvalidIndex(t *testing.T) {
349	// Arrange
350	mockFactory, _ := setupMockFactory(t)
351	sv := layout.NewStackedView(mockFactory)
352
353	// Act
354	err := sv.UpdateTitle(0, "Title")
355
356	// Assert
357	require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
358}
359
360func TestUpdateFavicon(t *testing.T) {
361	// Arrange
362	ctx := context.Background()
363	mockFactory, mockBox := setupMockFactory(t)
364	mockTitleBar, mockFavicon, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
365
366	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
367	mockContainer.EXPECT().SetVisible(true).Once()
368	mockTitleBar.EXPECT().AddCssClass("active").Once()
369
370	sv := layout.NewStackedView(mockFactory)
371	sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
372
373	mockFavicon.EXPECT().SetFromIconName("new-icon").Once()
374
375	// Act
376	err := sv.UpdateFavicon(0, "new-icon")
377
378	// Assert
379	require.NoError(t, err)
380}
381
382func TestGetContainer(t *testing.T) {
383	// Arrange
384	ctx := context.Background()
385	mockFactory, mockBox := setupMockFactory(t)
386	mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
387	_ = mockFavicon
388	_ = mockOverlay
389	_ = mockSpinner
390
391	mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
392	mockContainer.EXPECT().SetVisible(true).Once()
393	mockTitleBar.EXPECT().AddCssClass("active").Once()
394
395	sv := layout.NewStackedView(mockFactory)
396	sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
397
398	// Act
399	container, err := sv.GetContainer(0)
400
401	// Assert
402	require.NoError(t, err)
403	assert.Equal(t, mockContainer, container)
404}
405
406func TestGetContainer_InvalidIndex(t *testing.T) {
407	// Arrange
408	mockFactory, _ := setupMockFactory(t)
409	sv := layout.NewStackedView(mockFactory)
410
411	// Act
412	container, err := sv.GetContainer(0)
413
414	// Assert
415	require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
416	assert.Nil(t, container)
417}
418
419func TestWidget_ReturnsBoxWidget(t *testing.T) {
420	// Arrange
421	mockFactory, mockBox := setupMockFactory(t)
422	sv := layout.NewStackedView(mockFactory)
423
424	// Act
425	widget := sv.Widget()
426
427	// Assert
428	assert.Equal(t, mockBox, widget)
429}
430
431func TestNavigateNext_WrapsAround(t *testing.T) {
432	// Arrange
433	ctx := context.Background()
434	mockFactory, mockBox := setupMockFactory(t)
435
436	containers := make([]*mocks.MockWidget, 2)
437	titleBars := make([]*mocks.MockBoxWidget, 2)
438
439	for i := 0; i < 2; i++ {
440		titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
441		_ = mockFavicon
442		_ = mockOverlay
443		_ = mockSpinner
444
445		titleBars[i] = titleBar
446		containers[i] = container
447
448		titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
449		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
450		titleBars[i].EXPECT().AddCssClass("active").Maybe()
451		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
452	}
453
454	sv := layout.NewStackedView(mockFactory)
455	sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
456	sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
457
458	// Currently at index 1 (last added)
459	assert.Equal(t, 1, sv.ActiveIndex())
460
461	// Act - navigate next should wrap to 0
462	err := sv.NavigateNext(ctx)
463
464	// Assert
465	require.NoError(t, err)
466	assert.Equal(t, 0, sv.ActiveIndex())
467}
468
469func TestNavigatePrevious_WrapsAround(t *testing.T) {
470	// Arrange
471	ctx := context.Background()
472	mockFactory, mockBox := setupMockFactory(t)
473
474	containers := make([]*mocks.MockWidget, 2)
475	titleBars := make([]*mocks.MockBoxWidget, 2)
476
477	for i := 0; i < 2; i++ {
478		titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
479		_ = mockFavicon
480		_ = mockOverlay
481		_ = mockSpinner
482
483		titleBars[i] = titleBar
484		containers[i] = container
485
486		titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
487		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
488		titleBars[i].EXPECT().AddCssClass("active").Maybe()
489		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
490	}
491
492	sv := layout.NewStackedView(mockFactory)
493	sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
494	sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
495
496	// Set to first pane
497	sv.SetActive(ctx, 0)
498
499	// Act - navigate previous should wrap to 1
500	err := sv.NavigatePrevious(ctx)
501
502	// Assert
503	require.NoError(t, err)
504	assert.Equal(t, 1, sv.ActiveIndex())
505}
506
507func TestNavigateNext_EmptyStack(t *testing.T) {
508	// Arrange
509	ctx := context.Background()
510	mockFactory, _ := setupMockFactory(t)
511	sv := layout.NewStackedView(mockFactory)
512
513	// Act
514	err := sv.NavigateNext(ctx)
515
516	// Assert
517	require.ErrorIs(t, err, layout.ErrStackEmpty)
518}
519
520func TestNavigatePrevious_EmptyStack(t *testing.T) {
521	// Arrange
522	ctx := context.Background()
523	mockFactory, _ := setupMockFactory(t)
524	sv := layout.NewStackedView(mockFactory)
525
526	// Act
527	err := sv.NavigatePrevious(ctx)
528
529	// Assert
530	require.ErrorIs(t, err, layout.ErrStackEmpty)
531}
532
533func TestSetOnActivate_Callback(t *testing.T) {
534	// Arrange
535	mockFactory, _ := setupMockFactory(t)
536	sv := layout.NewStackedView(mockFactory)
537
538	callback := func(index int) {
539		_ = index // Used to test callback registration
540	}
541
542	// Act
543	sv.SetOnActivate(callback)
544
545	// We can't easily test the callback being triggered since it requires
546	// simulating a button click. The test verifies the method doesn't panic.
547	// Integration tests would verify the actual callback invocation.
548}
549
550// setupInsertPaneMocks creates mocks needed for InsertPaneAfter with position-aware insertion.
551// Returns only the titleBar and container mocks that are needed by test assertions.
552func setupInsertPaneMocks(
553	t *testing.T,
554	mockFactory *mocks.MockWidgetFactory,
555	mockBox *mocks.MockBoxWidget,
556	siblingContainer layout.Widget,
557) (*mocks.MockBoxWidget, *mocks.MockWidget) {
558	mockTitleBar := mocks.NewMockBoxWidget(t)
559	mockFavicon := mocks.NewMockImageWidget(t)
560	mockLabel := mocks.NewMockLabelWidget(t)
561	mockButton := mocks.NewMockButtonWidget(t)
562	mockContainer := mocks.NewMockWidget(t)
563
564	// Title bar creation
565	mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
566	mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
567	mockTitleBar.EXPECT().SetVexpand(false).Once()
568
569	// Favicon
570	mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
571	mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
572	mockFavicon.EXPECT().SetPixelSize(16).Once()
573	mockTitleBar.EXPECT().Append(mockFavicon).Once()
574
575	// Label
576	mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
577	mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
578	mockLabel.EXPECT().SetMaxWidthChars(30).Once()
579	mockLabel.EXPECT().SetHexpand(true).Once()
580	mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
581	mockTitleBar.EXPECT().Append(mockLabel).Once()
582
583	// Button wrapping title bar
584	mockFactory.EXPECT().NewButton().Return(mockButton).Once()
585	mockButton.EXPECT().SetChild(mockTitleBar).Once()
586	mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
587	mockButton.EXPECT().SetFocusOnClick(false).Once()
588	mockButton.EXPECT().SetVexpand(false).Once()
589	mockButton.EXPECT().SetHexpand(true).Once()
590	mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
591
592	// Position-aware insertion using InsertChildAfter
593	if siblingContainer != nil {
594		mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once()
595		mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once()
596	} else {
597		// Insert at beginning using Prepend
598		mockBox.EXPECT().Prepend(mockContainer).Once()
599		mockBox.EXPECT().Prepend(mockButton).Once()
600	}
601
602	return mockTitleBar, mockContainer
603}
604
605func TestInsertPaneAfter_AtBeginning(t *testing.T) {
606	// Arrange
607	ctx := context.Background()
608	mockFactory, mockBox := setupMockFactory(t)
609
610	// First pane - appended normally
611	mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
612	_ = mockFavicon1
613	_ = mockOverlay1
614	_ = mockSpinner1
615	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
616	mockContainer1.EXPECT().SetVisible(true).Once()
617	mockTitleBar1.EXPECT().AddCssClass("active").Once()
618
619	sv := layout.NewStackedView(mockFactory)
620	sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
621
622	// Second pane - inserted at beginning (afterIndex=-1)
623	mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
624
625	// Visibility updates
626	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
627	mockContainer1.EXPECT().SetVisible(false).Once()
628	mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
629
630	mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
631	mockContainer2.EXPECT().SetVisible(true).Once()
632	mockTitleBar2.EXPECT().AddCssClass("active").Once()
633
634	// Act
635	index := sv.InsertPaneAfter(ctx, -1, "pane-0", "Page 0", "", mockContainer2)
636
637	// Assert
638	assert.Equal(t, 0, index)
639	assert.Equal(t, 2, sv.Count())
640	assert.Equal(t, 0, sv.ActiveIndex())
641}
642
643func TestInsertPaneAfter_InMiddle(t *testing.T) {
644	// Arrange
645	ctx := context.Background()
646	mockFactory, mockBox := setupMockFactory(t)
647
648	// Setup 2 initial panes
649	containers := make([]*mocks.MockWidget, 2)
650	titleBars := make([]*mocks.MockBoxWidget, 2)
651
652	for i := 0; i < 2; i++ {
653		titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
654		titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
655		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
656		titleBars[i].EXPECT().AddCssClass("active").Maybe()
657		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
658	}
659
660	sv := layout.NewStackedView(mockFactory)
661	sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
662	sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
663
664	// Set active to first pane
665	require.NoError(t, sv.SetActive(ctx, 0))
666
667	// Insert new pane after index 0 (should become index 1)
668	mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])
669	mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe()
670	mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()
671	mockTitleBar3.EXPECT().AddCssClass("active").Maybe()
672	mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
673
674	// Act
675	index := sv.InsertPaneAfter(ctx, 0, "pane-1.5", "Page 1.5", "", mockContainer3)
676
677	// Assert
678	assert.Equal(t, 1, index)
679	assert.Equal(t, 3, sv.Count())
680	assert.Equal(t, 1, sv.ActiveIndex()) // New pane becomes active
681}
682
683func TestInsertPaneAfter_AtEnd(t *testing.T) {
684	// Arrange
685	ctx := context.Background()
686	mockFactory, mockBox := setupMockFactory(t)
687
688	// First pane
689	mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
690	_ = mockFavicon1
691	_ = mockOverlay1
692	_ = mockSpinner1
693	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
694	mockContainer1.EXPECT().SetVisible(true).Once()
695	mockTitleBar1.EXPECT().AddCssClass("active").Once()
696
697	sv := layout.NewStackedView(mockFactory)
698	sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
699
700	// Insert after last pane (afterIndex=0, becomes index 1)
701	mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
702
703	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
704	mockContainer1.EXPECT().SetVisible(false).Once()
705	mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
706
707	mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
708	mockContainer2.EXPECT().SetVisible(true).Once()
709	mockTitleBar2.EXPECT().AddCssClass("active").Once()
710
711	// Act
712	index := sv.InsertPaneAfter(ctx, 0, "pane-2", "Page 2", "", mockContainer2)
713
714	// Assert
715	assert.Equal(t, 1, index)
716	assert.Equal(t, 2, sv.Count())
717	assert.Equal(t, 1, sv.ActiveIndex())
718}
719
720func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
721	// Arrange
722	ctx := context.Background()
723	mockFactory, mockBox := setupMockFactory(t)
724
725	// Setup 3 initial panes: A, B, C
726	containers := make([]*mocks.MockWidget, 3)
727	titleBars := make([]*mocks.MockBoxWidget, 3)
728
729	for i := 0; i < 3; i++ {
730		titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
731		_ = mockFavicon
732		_ = mockOverlay
733		_ = mockSpinner
734
735		titleBars[i] = titleBar
736		containers[i] = container
737
738		titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
739		containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
740		titleBars[i].EXPECT().AddCssClass("active").Maybe()
741		titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
742	}
743
744	sv := layout.NewStackedView(mockFactory)
745	sv.AddPane(ctx, "pane-a", "A", "", containers[0]) // index 0
746	sv.AddPane(ctx, "pane-b", "B", "", containers[1]) // index 1
747	sv.AddPane(ctx, "pane-c", "C", "", containers[2]) // index 2
748
749	// Navigate to pane B (index 1)
750	require.NoError(t, sv.SetActive(ctx, 1))
751
752	// Insert D after B (should be at index 2, C moves to index 3)
753	mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])
754	mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe()
755	mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()
756	mockTitleBar4.EXPECT().AddCssClass("active").Maybe()
757	mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
758
759	// Act
760	index := sv.InsertPaneAfter(ctx, 1, "pane-d", "D", "", mockContainer4)
761
762	// Assert: Order should be A(0), B(1), D(2), C(3)
763	assert.Equal(t, 2, index)
764	assert.Equal(t, 4, sv.Count())
765	assert.Equal(t, 2, sv.ActiveIndex()) // D is now active
766
767	// Verify we can still get containers at expected indices
768	containerA, _ := sv.GetContainer(0)
769	containerB, _ := sv.GetContainer(1)
770	containerD, _ := sv.GetContainer(2)
771	containerC, _ := sv.GetContainer(3)
772
773	assert.Equal(t, containers[0], containerA)
774	assert.Equal(t, containers[1], containerB)
775	assert.Equal(t, mockContainer4, containerD)
776	assert.Equal(t, containers[2], containerC)
777}
778
779func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
780	// Arrange
781	ctx := context.Background()
782	mockFactory, mockBox := setupMockFactory(t)
783
784	// First pane
785	mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
786	_ = mockFavicon1
787	_ = mockOverlay1
788	_ = mockSpinner1
789	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
790	mockContainer1.EXPECT().SetVisible(true).Once()
791	mockTitleBar1.EXPECT().AddCssClass("active").Once()
792
793	sv := layout.NewStackedView(mockFactory)
794	sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
795
796	// Try to insert at invalid index (100) - should clamp to end
797	mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
798
799	mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
800	mockContainer1.EXPECT().SetVisible(false).Once()
801	mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
802
803	mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
804	mockContainer2.EXPECT().SetVisible(true).Once()
805	mockTitleBar2.EXPECT().AddCssClass("active").Once()
806
807	// Act - afterIndex=100 should be clamped to 0 (last valid index)
808	index := sv.InsertPaneAfter(ctx, 100, "pane-2", "Page 2", "", mockContainer2)
809
810	// Assert
811	assert.Equal(t, 1, index)
812	assert.Equal(t, 2, sv.Count())
813}

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