detached 3eb9180

test(ui): cover floating browsing context routing

commit
3eb91808852eb7b1dbdb0f52deb83d03b7eb4349
author
brice · · 2026-07-25 08:33 UTC
committer
brice · · 2026-07-26 16:42 UTC
parent
1ee31e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +89 -0
M docs/reference/floating-pane.md +6 -0
@@ -16,6 +16,12 @@ The floating pane is an overlay workspace pane for quick-access pages like searc
1616 - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView.
1717 - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state.
1818
19+### Links and popups
20+
21+New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work.
22+
23+Detached Dumber windows remain open when the source floating pane is hidden or closed.
24+
1925 ## Configuration
2026
2127 | Key | Type | Default | Description |
@@ -16,6 +16,12 @@ The floating pane is an overlay workspace pane for quick-access pages like searc
16 - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView.16 - `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView.
17 - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state.17 - After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state.
18 18
19+### Links and popups
20+
21+New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work.
22+
23+Detached Dumber windows remain open when the source floating pane is hidden or closed.
24+
19 ## Configuration25 ## Configuration
20 26
21 | Key | Type | Default | Description |27 | Key | Type | Default | Description |
M internal/ui/app_browser_launch_test.go +18 -0
@@ -1313,6 +1313,24 @@ func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
13131313 }
13141314 }
13151315
1316+func TestApp_PopupOwnerWindowIDForFloatingPaneUsesSourceTabWindow(t *testing.T) {
1317+ tabID := entity.TabID("source-tab")
1318+ owner := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
1319+ paneID := entity.PaneID("floating-pane")
1320+ app := &App{
1321+ browserWindows: map[string]*browserWindow{owner.id: owner},
1322+ windowForTab: map[entity.TabID]*browserWindow{tabID: owner},
1323+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1324+ {tabID: tabID, sessionID: "profile"}: {paneID: paneID},
1325+ },
1326+ }
1327+
1328+ windowID, ok := app.popupOwnerWindowIDForPane(paneID)
1329+
1330+ require.True(t, ok)
1331+ require.Equal(t, owner.id, windowID)
1332+}
1333+
13161334 func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
13171335 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
13181336 newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -1313,6 +1313,24 @@ func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
1313 }1313 }
1314 }1314 }
1315 1315
1316+func TestApp_PopupOwnerWindowIDForFloatingPaneUsesSourceTabWindow(t *testing.T) {
1317+ tabID := entity.TabID("source-tab")
1318+ owner := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
1319+ paneID := entity.PaneID("floating-pane")
1320+ app := &App{
1321+ browserWindows: map[string]*browserWindow{owner.id: owner},
1322+ windowForTab: map[entity.TabID]*browserWindow{tabID: owner},
1323+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1324+ {tabID: tabID, sessionID: "profile"}: {paneID: paneID},
1325+ },
1326+ }
1327+
1328+ windowID, ok := app.popupOwnerWindowIDForPane(paneID)
1329+
1330+ require.True(t, ok)
1331+ require.Equal(t, owner.id, windowID)
1332+}
1333+
1316 func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {1334 func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
1317 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}1335 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1318 newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}1336 newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
M internal/ui/browser_window.go +9 -0
@@ -366,6 +366,7 @@ func (a *App) removeBrowserWindow(id string) {
366366 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
367367
368368 a.removeBrowserWindowOrder(id)
369+ a.releaseNativePopupsForBrowserWindow(id)
369370 a.releaseBrowserWindowTabs(context.Background(), id, removed)
370371 if removed != nil {
371372 removed.teardownForDestroy()
@@ -382,6 +383,14 @@ func (a *App) removeBrowserWindow(id string) {
382383 }
383384 }
384385
386+func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {
387+ for popupID, popup := range a.nativePopupWindows {
388+ if popup != nil && popup.parentWindowID == windowID {
389+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
390+ }
391+ }
392+}
393+
385394 func (a *App) removeBrowserWindowOrder(id string) {
386395 for i, wid := range a.browserWindowOrder {
387396 if wid == id {
@@ -366,6 +366,7 @@ func (a *App) removeBrowserWindow(id string) {
366 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow366 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
367 367
368 a.removeBrowserWindowOrder(id)368 a.removeBrowserWindowOrder(id)
369+ a.releaseNativePopupsForBrowserWindow(id)
369 a.releaseBrowserWindowTabs(context.Background(), id, removed)370 a.releaseBrowserWindowTabs(context.Background(), id, removed)
370 if removed != nil {371 if removed != nil {
371 removed.teardownForDestroy()372 removed.teardownForDestroy()
@@ -382,6 +383,14 @@ func (a *App) removeBrowserWindow(id string) {
382 }383 }
383 }384 }
384 385
386+func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {
387+ for popupID, popup := range a.nativePopupWindows {
388+ if popup != nil && popup.parentWindowID == windowID {
389+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
390+ }
391+ }
392+}
393+
385 func (a *App) removeBrowserWindowOrder(id string) {394 func (a *App) removeBrowserWindowOrder(id string) {
386 for i, wid := range a.browserWindowOrder {395 for i, wid := range a.browserWindowOrder {
387 if wid == id {396 if wid == id {
M internal/ui/browser_window_test.go +26 -0
@@ -8,6 +8,7 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -274,6 +275,31 @@ func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *t
274275 assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
275276 }
276277
278+func TestBrowserWindow_RemoveBrowserWindowReleasesOnlyRelatedNativePopups(t *testing.T) {
279+ closedWebView := portmocks.NewMockWebView(t)
280+ closedWebView.EXPECT().IsDestroyed().Return(false).Once()
281+ closedWebView.EXPECT().Destroy().Once()
282+ unrelatedWebView := portmocks.NewMockWebView(t)
283+ closedShell := &popupDestroySpy{}
284+ unrelatedShell := &popupDestroySpy{}
285+ removed := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
286+ remaining := &browserWindow{id: "other-window", tabs: entity.NewTabList()}
287+ app := &App{
288+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
289+ nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
290+ 1: {popupID: 1, parentWindowID: removed.id, webView: closedWebView, popupWindow: closedShell},
291+ 2: {popupID: 2, parentWindowID: remaining.id, webView: unrelatedWebView, popupWindow: unrelatedShell},
292+ },
293+ }
294+
295+ app.removeBrowserWindow(removed.id)
296+
297+ assert.NotContains(t, app.nativePopupWindows, port.WebViewID(1))
298+ assert.Contains(t, app.nativePopupWindows, port.WebViewID(2))
299+ assert.Equal(t, 1, closedShell.destroyed)
300+ assert.Zero(t, unrelatedShell.destroyed)
301+}
302+
277303 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
278304 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
279305 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
@@ -8,6 +8,7 @@ import (
8 "unsafe"8 "unsafe"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"14 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -274,6 +275,31 @@ func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *t
274 assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))275 assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
275 }276 }
276 277
278+func TestBrowserWindow_RemoveBrowserWindowReleasesOnlyRelatedNativePopups(t *testing.T) {
279+ closedWebView := portmocks.NewMockWebView(t)
280+ closedWebView.EXPECT().IsDestroyed().Return(false).Once()
281+ closedWebView.EXPECT().Destroy().Once()
282+ unrelatedWebView := portmocks.NewMockWebView(t)
283+ closedShell := &popupDestroySpy{}
284+ unrelatedShell := &popupDestroySpy{}
285+ removed := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
286+ remaining := &browserWindow{id: "other-window", tabs: entity.NewTabList()}
287+ app := &App{
288+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
289+ nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
290+ 1: {popupID: 1, parentWindowID: removed.id, webView: closedWebView, popupWindow: closedShell},
291+ 2: {popupID: 2, parentWindowID: remaining.id, webView: unrelatedWebView, popupWindow: unrelatedShell},
292+ },
293+ }
294+
295+ app.removeBrowserWindow(removed.id)
296+
297+ assert.NotContains(t, app.nativePopupWindows, port.WebViewID(1))
298+ assert.Contains(t, app.nativePopupWindows, port.WebViewID(2))
299+ assert.Equal(t, 1, closedShell.destroyed)
300+ assert.Zero(t, unrelatedShell.destroyed)
301+}
302+
277 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {303 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
278 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))304 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
279 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))305 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
M internal/ui/floating_pane_test.go +30 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1011 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
@@ -312,6 +313,35 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
312313 assert.False(t, handled)
313314 }
314315
316+func 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+
315345 func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {
316346 app, _, session := newFloatingPaneTestApp(t)
317347
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/ui/component"9 "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
10 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"11 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -312,6 +313,35 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
312 assert.False(t, handled)313 assert.False(t, handled)
313 }314 }
314 315
316+func 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+
315 func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {345 func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) {
316 app, _, session := newFloatingPaneTestApp(t)346 app, _, session := newFloatingPaneTestApp(t)
317 347

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