v0.27.2 5960dd8

floating_pane_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"testing"
6	"unsafe"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/ui/component"
10	layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13)
14
15func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
16	overlay := layoutmocks.NewMockOverlayWidget(t)
17	overlay.EXPECT().SetHexpand(false).Once()
18	overlay.EXPECT().SetVexpand(false).Once()
19	overlay.EXPECT().AddCssClass("floating-pane-container").Once()
20	overlay.EXPECT().AddCssClass("pane-border").Once()
21	overlay.EXPECT().AddCssClass("pane-active").Once()
22
23	decorateFloatingOverlay(overlay)
24}
25
26func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) {
27	workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
28	floatingOverlay := layoutmocks.NewMockWidget(t)
29
30	workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once()
31	workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
32
33	configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
34}
35
36func TestShowFloatingWidget_AddsVisibleClassAndEnablesInteraction(t *testing.T) {
37	widget := layoutmocks.NewMockWidget(t)
38
39	widget.EXPECT().AddCssClass(floatingPaneVisibleClass).Once()
40	widget.EXPECT().SetCanTarget(true).Once()
41	widget.EXPECT().SetCanFocus(true).Once()
42
43	showFloatingWidget(widget)
44}
45
46func TestHideFloatingWidget_RemovesVisibleClassAndDisablesInteraction(t *testing.T) {
47	widget := layoutmocks.NewMockWidget(t)
48
49	widget.EXPECT().RemoveCssClass(floatingPaneVisibleClass).Once()
50	widget.EXPECT().SetCanTarget(false).Once()
51	widget.EXPECT().SetCanFocus(false).Once()
52
53	hideFloatingWidget(widget)
54}
55
56func TestFocusFloatingSessionContent_GrabsWebViewFocusWhenOmniboxHidden(t *testing.T) {
57	session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:google")
58	session.pane.Show()
59
60	widget := layoutmocks.NewMockWidget(t)
61	widget.EXPECT().GrabFocus().Return(true).Once()
62	session.focusWidget = widget
63
64	focusFloatingSessionContent(session)
65}
66
67func TestFocusFloatingSessionContent_SkipsWhenOmniboxVisible(t *testing.T) {
68	session := newFloatingPaneSession(entity.TabID("tab-1"), floatingSessionIDDefault)
69	require.NoError(t, session.pane.ShowToggle(context.Background()))
70	require.True(t, session.pane.IsOmniboxVisible())
71
72	widget := layoutmocks.NewMockWidget(t)
73	session.focusWidget = widget
74
75	focusFloatingSessionContent(session)
76}
77
78func TestFloatingAllocationRect_CentersRequestedSize(t *testing.T) {
79	x, y, width, height, ok := floatingAllocationRect(1000, 700, 820, 504)
80
81	require.True(t, ok)
82	assert.Equal(t, 90, x)
83	assert.Equal(t, 98, y)
84	assert.Equal(t, 820, width)
85	assert.Equal(t, 504, height)
86}
87
88func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) {
89	x, y, width, height, ok := floatingAllocationRect(1000, 700, 1200, 900)
90
91	require.True(t, ok)
92	assert.Equal(t, 0, x)
93	assert.Equal(t, 0, y)
94	assert.Equal(t, 1000, width)
95	assert.Equal(t, 700, height)
96}
97
98func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) {
99	// cGtkAllocation mirrors GTK's GdkRectangle/GtkAllocation layout used by
100	// writeOverlayAllocation: four int32 fields in order. allocationPtr uses
101	// unsafe.Pointer to simulate the C allocation callback memory.
102	type cGtkAllocation struct {
103		X      int32
104		Y      int32
105		Width  int32
106		Height int32
107	}
108
109	rect := cGtkAllocation{}
110	allocationPtr := (*uintptr)(unsafe.Pointer(&rect))
111
112	ok := writeOverlayAllocation(allocationPtr, 11, 22, 33, 44)
113
114	require.True(t, ok)
115	assert.Equal(t, int32(11), rect.X)
116	assert.Equal(t, int32(22), rect.Y)
117	assert.Equal(t, int32(33), rect.Width)
118	assert.Equal(t, int32(44), rect.Height)
119}
120
121func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
122	app, tabID, session := newFloatingPaneTestApp(t)
123
124	err := app.ToggleFloatingPane(context.Background())
125	require.NoError(t, err)
126	assert.True(t, session.pane.IsVisible())
127	assert.Equal(t, "about:blank", session.pane.CurrentURL())
128
129	err = app.ToggleFloatingPane(context.Background())
130	require.NoError(t, err)
131	assert.False(t, session.pane.IsVisible())
132	assert.Equal(t, "about:blank", session.pane.CurrentURL())
133
134	_, gotTabID := app.activeFloatingSession()
135	assert.Equal(t, tabID, gotTabID)
136}
137
138func TestFloatingPane_ProfileShortcutOpensConfiguredURL(t *testing.T) {
139	app, _, session := newFloatingPaneTestApp(t)
140
141	err := app.OpenFloatingPaneURL(context.Background(), "https://google.com")
142	require.NoError(t, err)
143
144	assert.True(t, session.pane.IsVisible())
145	assert.False(t, session.pane.IsOmniboxVisible())
146	assert.Equal(t, "https://google.com", session.pane.CurrentURL())
147}
148
149func TestFloatingPane_ActivePaneSwitchPreservesSession(t *testing.T) {
150	app, tabID, session := newFloatingPaneTestApp(t)
151
152	err := app.OpenFloatingPaneURL(context.Background(), "https://example.com/start")
153	require.NoError(t, err)
154
155	activeTab := app.tabs.Find(tabID)
156	require.NotNil(t, activeTab)
157	activeTab.Workspace.ActivePaneID = entity.PaneID("pane-two")
158
159	err = app.ToggleFloatingPane(context.Background())
160	require.NoError(t, err)
161	err = app.ToggleFloatingPane(context.Background())
162	require.NoError(t, err)
163
164	assert.Equal(t, "https://example.com/start", session.pane.CurrentURL())
165}
166
167func TestFloatingPane_OmniboxNavigationTargetsFloatingSession(t *testing.T) {
168	app, _, session := newFloatingPaneTestApp(t)
169
170	require.NoError(t, session.pane.ShowToggle(context.Background()))
171	require.True(t, session.pane.IsOmniboxVisible())
172
173	err := app.navigateFromOmnibox(context.Background(), "https://example.com/omnibox")
174	require.NoError(t, err)
175	assert.Equal(t, "https://example.com/omnibox", session.pane.CurrentURL())
176}
177
178func TestFloatingPane_HandleViewportTick_RecalculatesOnWorkspaceResize(t *testing.T) {
179	ctx := context.Background()
180	overlay := layoutmocks.NewMockOverlayWidget(t)
181	width := 1000
182	height := 700
183	overlay.EXPECT().GetAllocatedWidth().RunAndReturn(func() int { return width }).Maybe()
184	overlay.EXPECT().GetAllocatedHeight().RunAndReturn(func() int { return height }).Maybe()
185
186	widget := layoutmocks.NewMockWidget(t)
187	pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
188		WidthPct:       0.82,
189		HeightPct:      0.72,
190		FallbackWidth:  floatingPaneFallbackWidth,
191		FallbackHeight: floatingPaneFallbackHeight,
192	})
193	require.NoError(t, pane.ShowURL(ctx, "https://example.com"))
194
195	session := &floatingWorkspaceSession{pane: pane, overlay: overlay, widget: widget}
196	app := &App{}
197
198	widget.EXPECT().SetSizeRequest(820, 504).Once()
199	assert.True(t, app.handleFloatingViewportTick(session))
200
201	width = 1200
202	height = 900
203	widget.EXPECT().SetSizeRequest(983, 648).Once()
204	assert.True(t, app.handleFloatingViewportTick(session))
205
206	pane.Hide(ctx)
207	assert.False(t, app.handleFloatingViewportTick(session))
208}
209
210func TestFloatingPane_HideShowAfterURIUpdateKeepsOmniboxHidden(t *testing.T) {
211	app, _, session := newFloatingPaneTestApp(t)
212
213	require.NoError(t, app.ToggleFloatingPane(context.Background()))
214	require.True(t, session.pane.IsOmniboxVisible())
215
216	app.updateFloatingSessionURI(session.paneID, "https://google.com")
217
218	require.NoError(t, app.ToggleFloatingPane(context.Background()))
219	require.NoError(t, app.ToggleFloatingPane(context.Background()))
220
221	assert.Equal(t, "https://google.com", session.pane.CurrentURL())
222	assert.False(t, session.pane.IsOmniboxVisible())
223}
224
225func TestFloatingPane_ProfileSessionsRemainIndependent(t *testing.T) {
226	app, tabID, _ := newFloatingPaneTestApp(t)
227
228	gmailSession := newFloatingPaneSession(tabID, "profile:gmail")
229	blankSession := newFloatingPaneSession(tabID, "profile:blank")
230	app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = gmailSession
231	app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:blank"}] = blankSession
232
233	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
234	require.NoError(t, app.ToggleFloatingPane(context.Background()))
235	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:blank", "about:blank"))
236
237	assert.Equal(t, "https://mail.google.com", gmailSession.pane.CurrentURL())
238	assert.False(t, gmailSession.pane.IsVisible())
239	assert.Equal(t, "about:blank", blankSession.pane.CurrentURL())
240	assert.True(t, blankSession.pane.IsVisible())
241	assert.NotEqual(t, gmailSession.paneID, blankSession.paneID)
242}
243
244func TestFloatingPane_ProfileShortcutTogglesAndPreservesSessionState(t *testing.T) {
245	app, tabID, _ := newFloatingPaneTestApp(t)
246	session := newFloatingPaneSession(tabID, "profile:gmail")
247	app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
248
249	baseURL := "https://mail.google.com"
250	draftURL := "https://mail.google.com/mail/u/0/#inbox?compose=new"
251
252	// First open: navigates to base URL
253	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
254	assert.True(t, session.pane.IsVisible())
255	assert.Equal(t, baseURL, session.pane.CurrentURL())
256
257	// Simulate user navigating within gmail (compose draft etc.)
258	app.updateFloatingSessionURI(session.paneID, draftURL)
259	assert.Equal(t, draftURL, session.pane.CurrentURL())
260
261	// Second press: hides (toggle off), preserves draft URL
262	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
263	assert.False(t, session.pane.IsVisible())
264	assert.Equal(t, draftURL, session.pane.CurrentURL())
265
266	// Third press: shows again without re-navigating, draft URL preserved
267	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
268	assert.True(t, session.pane.IsVisible())
269	assert.Equal(t, draftURL, session.pane.CurrentURL())
270}
271
272func TestFloatingPane_ProfileRapidToggleNeverShowsAboutBlank(t *testing.T) {
273	app, tabID, _ := newFloatingPaneTestApp(t)
274	session := newFloatingPaneSession(tabID, "profile:gmail")
275	app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
276
277	baseURL := "https://mail.google.com"
278
279	// Rapid toggle 10 times: should never end up on about:blank
280	for i := 0; i < 10; i++ {
281		require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
282		assert.NotEqual(t, "about:blank", session.pane.CurrentURL(),
283			"iteration %d: profile session should never show about:blank", i)
284		if session.pane.IsVisible() {
285			assert.Equal(t, baseURL, session.pane.CurrentURL())
286		}
287	}
288}
289
290func TestFloatingPane_DefaultSessionStillUsesAboutBlank(t *testing.T) {
291	app, _, session := newFloatingPaneTestApp(t)
292
293	require.NoError(t, app.ToggleFloatingPane(context.Background()))
294	assert.True(t, session.pane.IsVisible())
295	assert.Equal(t, "about:blank", session.pane.CurrentURL())
296}
297
298func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
299	app, tabID, _ := newFloatingPaneTestApp(t)
300	session := newFloatingPaneSession(tabID, "profile:gmail")
301	key := floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}
302	app.floatingSessions[key] = session
303
304	require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
305
306	handled := app.closeAndReleaseActiveFloatingPane(context.Background())
307	assert.True(t, handled)
308	_, stillExists := app.floatingSessions[key]
309	assert.False(t, stillExists)
310
311	handled = app.closeAndReleaseActiveFloatingPane(context.Background())
312	assert.False(t, handled)
313}
314
315func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {
316	app, _, session := newFloatingPaneTestApp(t)
317
318	require.NoError(t, app.ToggleFloatingPane(context.Background()))
319	require.True(t, session.pane.IsVisible())
320
321	handled := app.handleGlobalEscape(context.Background())
322	assert.True(t, handled)
323	assert.False(t, session.pane.IsVisible())
324}
325
326func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) {
327	app, _, session := newFloatingPaneTestApp(t)
328
329	require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault))
330	assert.True(t, session.pane.IsVisible())
331	assert.Equal(t, "about:blank", session.pane.CurrentURL())
332
333	require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault, "https://example.com"))
334	assert.True(t, session.pane.IsVisible())
335	assert.Equal(t, "https://example.com", session.pane.CurrentURL())
336}
337
338func TestFloatingPane_EnsureFloatingSession_CachedAllowsNilWorkspaceView(t *testing.T) {
339	app, tabID, session := newFloatingPaneTestApp(t)
340
341	got, err := app.ensureFloatingSession(context.Background(), tabID, floatingSessionIDDefault, nil)
342	require.NoError(t, err)
343	assert.Same(t, session, got)
344}
345
346func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
347	session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
348	session.resizeWatcherActive = true
349	session.resizeTickID = 42
350	session.appliedWidth = 960
351	session.appliedHeight = 640
352
353	app := &App{}
354	app.hideFloatingSession(context.Background(), session)
355
356	assert.False(t, session.resizeWatcherActive)
357	assert.Equal(t, uint(0), session.resizeTickID)
358	assert.Equal(t, 0, session.appliedWidth)
359	assert.Equal(t, 0, session.appliedHeight)
360}
361
362func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
363	t.Helper()
364
365	tabID := entity.TabID("tab-1")
366	workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
367	tabs := entity.NewTabList()
368	tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})
369
370	session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
371
372	app := &App{
373		tabs:           tabs,
374		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
375		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
376			{tabID: tabID, sessionID: floatingSessionIDDefault}: session,
377		},
378	}
379
380	return app, tabID, session
381}
382
383func newFloatingPaneSession(tabID entity.TabID, sessionID string) *floatingWorkspaceSession {
384	return &floatingWorkspaceSession{
385		paneID: floatingPaneIDForSession(tabID, sessionID),
386		pane: component.NewFloatingPane(nil, component.FloatingPaneOptions{
387			WidthPct:       0.82,
388			HeightPct:      0.72,
389			FallbackWidth:  floatingPaneFallbackWidth,
390			FallbackHeight: floatingPaneFallbackHeight,
391			OnNavigate: func(context.Context, string) error {
392				return nil
393			},
394		}),
395	}
396}

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