v0.25.0 2e6c485

workspace_view_test.go RawHistoryFolder

1package component_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/domain/entity"
12	"github.com/bnema/dumber/internal/ui/component"
13	"github.com/bnema/dumber/internal/ui/layout"
14	"github.com/bnema/dumber/internal/ui/layout/mocks"
15)
16
17func setupLoadingSkeletonMocksWorkspace(
18	mockFactory *mocks.MockWidgetFactory,
19	mockOverlay *mocks.MockOverlayWidget,
20	mockLoadingContainer *mocks.MockBoxWidget,
21	mockLoadingContent *mocks.MockBoxWidget,
22	mockLoadingSpinner *mocks.MockSpinnerWidget,
23	mockLoadingLogo *mocks.MockImageWidget,
24) {
25	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
26	mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
27	mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
28	mockLoadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
29	mockLoadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
30	mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
31	mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
32	mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
33	mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
34
35	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
36	mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
37	mockLoadingContent.EXPECT().SetValign(mock.Anything).Maybe()
38	mockLoadingContent.EXPECT().SetCanFocus(false).Maybe()
39	mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
40	mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
41
42	mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
43	mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
44	mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
45	mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
46	mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
47	mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
48	mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
49	mockLoadingSpinner.EXPECT().Start().Maybe()
50
51	mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
52	mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
53	mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
54	mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
55	mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
56	mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
57	mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
58	mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
59	mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60
61	mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
62	mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
63	mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
64
65	mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
66	mockOverlay.EXPECT().SetClipOverlay(mockLoadingContainer, false).Once()
67	mockOverlay.EXPECT().SetMeasureOverlay(mockLoadingContainer, false).Once()
68}
69
70func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {
71	ctx := context.Background()
72	mockContainer := mocks.NewMockBoxWidget(t)
73	mockOverlay := mocks.NewMockOverlayWidget(t)
74
75	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
76	mockContainer.EXPECT().SetHexpand(true).Once()
77	mockContainer.EXPECT().SetVexpand(true).Once()
78	mockContainer.EXPECT().SetVisible(true).Once()
79
80	mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
81	mockOverlay.EXPECT().SetHexpand(true).Once()
82	mockOverlay.EXPECT().SetVexpand(true).Once()
83	mockOverlay.EXPECT().SetChild(mockContainer).Once()
84	mockOverlay.EXPECT().SetVisible(true).Once()
85
86	return ctx, mockContainer
87}
88
89func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) {
90	mockOverlay := mocks.NewMockOverlayWidget(t)
91	mockBorderBox := mocks.NewMockBoxWidget(t)
92
93	mockLoadingContainer := mocks.NewMockBoxWidget(t)
94	mockLoadingContent := mocks.NewMockBoxWidget(t)
95	mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
96	mockLoadingLogo := mocks.NewMockImageWidget(t)
97
98	mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
99	mockOverlay.EXPECT().SetHexpand(true).Once()
100	mockOverlay.EXPECT().SetVexpand(true).Once()
101	mockOverlay.EXPECT().SetVisible(true).Once()
102	mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
103
104	setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105
106	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
107	mockBorderBox.EXPECT().SetCanFocus(false).Once()
108	mockBorderBox.EXPECT().SetCanTarget(false).Once()
109	mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
110	mockBorderBox.EXPECT().SetHexpand(true).Once()
111	mockBorderBox.EXPECT().SetVexpand(true).Once()
112	mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
113	mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
114	mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
115
116	// GtkWidget is called when attaching hover handler - return nil for tests
117	mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
118
119	return mockOverlay, mockBorderBox
120}
121
122func setupStackedLeafMocks(
123	t *testing.T,
124	mockFactory *mocks.MockWidgetFactory,
125	container *mocks.MockOverlayWidget,
126) *mocks.MockBoxWidget {
127	mockStackBox := mocks.NewMockBoxWidget(t)
128	mockTitleBar := mocks.NewMockBoxWidget(t)
129	mockFavicon := mocks.NewMockImageWidget(t)
130	mockLabel := mocks.NewMockLabelWidget(t)
131	mockCloseButton := mocks.NewMockButtonWidget(t)
132
133	mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134	mockStackBox.EXPECT().SetHexpand(true).Once()
135	mockStackBox.EXPECT().SetVexpand(true).Once()
136
137	// Title bar creation - now directly used without button wrapper
138	mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
139	mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
140	mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
141	mockTitleBar.EXPECT().SetVexpand(false).Once()
142	mockTitleBar.EXPECT().SetHexpand(true).Once()
143
144	mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
145	mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
146	mockFavicon.EXPECT().SetPixelSize(16).Once()
147	mockTitleBar.EXPECT().Append(mockFavicon).Once()
148
149	mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLabel).Once()
150	mockLabel.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
151	mockLabel.EXPECT().SetMaxWidthChars(30).Once()
152	mockLabel.EXPECT().SetHexpand(true).Once()
153	mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
154	mockTitleBar.EXPECT().Append(mockLabel).Once()
155
156	// Close button (uses SetIconName directly instead of child image)
157	mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
158	mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
159	mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
160	mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
161	mockCloseButton.EXPECT().SetVexpand(false).Once()
162	mockCloseButton.EXPECT().SetHexpand(false).Once()
163	mockTitleBar.EXPECT().Append(mockCloseButton).Once()
164
165	// GestureClick is added to titleBar via AddController
166	mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167
168	// Close button click handler
169	mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
170
171	// Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172	mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
173
174	// Adding to main box - now titleBar is added directly (no button wrapper)
175	mockStackBox.EXPECT().Append(mockTitleBar).Once()
176	mockStackBox.EXPECT().Append(container).Once()
177
178	// Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
179	mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
180	container.EXPECT().SetVisible(true).Once()
181	mockTitleBar.EXPECT().AddCssClass("active").Once()
182
183	return mockStackBox
184}
185
186func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
187	// Arrange
188	mockFactory := mocks.NewMockWidgetFactory(t)
189	ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
190
191	// Act
192	wv := component.NewWorkspaceView(ctx, mockFactory)
193
194	// Assert
195	require.NotNil(t, wv)
196	assert.Equal(t, 0, wv.PaneCount())
197	assert.Equal(t, mockBox, wv.Container())
198}
199
200func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
201	// Arrange
202	mockFactory := mocks.NewMockWidgetFactory(t)
203	ctx, _ := setupWorkspaceViewBase(t, mockFactory)
204
205	wv := component.NewWorkspaceView(ctx, mockFactory)
206
207	// Act
208	err := wv.SetWorkspace(ctx, nil)
209
210	// Assert
211	assert.ErrorIs(t, err, component.ErrNilWorkspace)
212}
213
214func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
215	// Arrange
216	mockFactory := mocks.NewMockWidgetFactory(t)
217	ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
218
219	wv := component.NewWorkspaceView(ctx, mockFactory)
220
221	mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory)
222	mockStackBox := setupStackedLeafMocks(t, mockFactory, mockOverlay)
223
224	mockStackBox.EXPECT().SetVisible(true).Once()
225	mockBox.EXPECT().Append(mockStackBox).Once()
226	mockBox.EXPECT().AddCssClass("single-pane").Once()
227	mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
228
229	pane := entity.NewPane(entity.PaneID("pane-1"))
230	node := &entity.PaneNode{ID: "node-1", Pane: pane}
231
232	ws := &entity.Workspace{
233		ID:           "ws-1",
234		Root:         node,
235		ActivePaneID: pane.ID,
236	}
237
238	// Act
239	err := wv.SetWorkspace(ctx, ws)
240
241	// Assert
242	require.NoError(t, err)
243	assert.Equal(t, 1, wv.PaneCount())
244	assert.Equal(t, pane.ID, wv.GetActivePaneID())
245	assert.NotNil(t, wv.GetPaneView(pane.ID))
246}
247
248func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
249	// Arrange
250	mockFactory := mocks.NewMockWidgetFactory(t)
251	ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
252
253	wv := component.NewWorkspaceView(ctx, mockFactory)
254
255	// Setup two panes
256	pane1 := entity.NewPane(entity.PaneID("pane-1"))
257	pane2 := entity.NewPane(entity.PaneID("pane-2"))
258	node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
259	node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
260
261	// Create mock widgets for split
262	mockPaned := mocks.NewMockPanedWidget(t)
263	mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory)
264	mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1)
265	mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory)
266	mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2)
267
268	// Split view creation
269	mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
270	mockPaned.EXPECT().SetResizeStartChild(true).Once()
271	mockPaned.EXPECT().SetResizeEndChild(true).Once()
272	mockPaned.EXPECT().SetVisible(true).Twice()
273	mockStackBox1.EXPECT().SetVisible(true).Once()
274	mockStackBox2.EXPECT().SetVisible(true).Once()
275	mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
276	mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
277	mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
278	mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
279	mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
280	mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
281
282	mockBox.EXPECT().Append(mockPaned).Once()
283
284	// Initial active pane is pane-1
285	mockBox.EXPECT().RemoveCssClass("single-pane").Once()
286	mockBorderBox1.EXPECT().AddCssClass("pane-active").Once()
287
288	splitNode := &entity.PaneNode{
289		ID:         "split",
290		SplitDir:   entity.SplitHorizontal,
291		SplitRatio: 0.5,
292		Children:   []*entity.PaneNode{node1, node2},
293	}
294
295	ws := &entity.Workspace{
296		ID:           "ws-1",
297		Root:         splitNode,
298		ActivePaneID: pane1.ID,
299	}
300
301	err := wv.SetWorkspace(ctx, ws)
302	require.NoError(t, err)
303
304	// Now change active pane
305	mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()
306	mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()
307
308	// Act
309	err = wv.SetActivePaneID(pane2.ID)
310
311	// Assert
312	require.NoError(t, err)
313	assert.Equal(t, pane2.ID, wv.GetActivePaneID())
314}
315
316func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
317	// Arrange
318	mockFactory := mocks.NewMockWidgetFactory(t)
319	ctx, _ := setupWorkspaceViewBase(t, mockFactory)
320
321	wv := component.NewWorkspaceView(ctx, mockFactory)
322
323	// Act
324	err := wv.SetActivePaneID(entity.PaneID("non-existent"))
325
326	// Assert
327	assert.ErrorIs(t, err, component.ErrPaneNotFound)
328}

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