main 5041353

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

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