detached ec8878f

feat(ui): open floating page requests in browser windows

commit
ec8878f2baedc04f1c7c888682a788e1a612a67a
author
brice · · 2026-07-25 06:23 UTC
committer
brice · · 2026-07-26 16:42 UTC
parent
a6c7a7f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +629 -130
M internal/ui/app.go +37 -31
@@ -13,6 +13,7 @@ import (
1313 "time"
1414 "unsafe"
1515
16+ "github.com/bnema/dumber/internal/application/dto"
1617 "github.com/bnema/dumber/internal/application/port"
1718 "github.com/bnema/dumber/internal/application/usecase"
1819 "github.com/bnema/dumber/internal/domain/entity"
@@ -3077,9 +3078,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
30773078 }
30783079 })
30793080 // Wire popup tab WebView attachment
3080- a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
3081- a.attachPopupToTab(ctx, tabID, pane, wv)
3082- })
3081+ a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)
30833082
30843083 for _, bw := range a.browserWindows {
30853084 a.wireBrowserWindowTabBar(ctx, bw)
@@ -3157,13 +3156,14 @@ func (a *App) initCoordinators(ctx context.Context) {
31573156 &runtimeCfg.Workspace.BrowsingContexts,
31583157 a.generateID,
31593158 )
3160- a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
3161- bw := a.browserWindowForAnyPane(paneID)
3162- if bw == nil {
3163- return "", false
3159+ a.contentCoord.SetPopupSourceHostResolver(func(paneID entity.PaneID) dto.SourceHostKind {
3160+ if a.floatingSessionByPaneID(paneID) != nil {
3161+ return dto.SourceHostFloating
31643162 }
3165- return bw.id, true
3163+ return dto.SourceHostWorkspace
31663164 })
3165+ a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane)
3166+ a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow)
31673167 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
31683168 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
31693169 a.activateBrowserWindow(bw)
@@ -3930,55 +3930,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
39303930
39313931 // attachPopupToTab attaches a popup WebView to a newly created tab.
39323932 // This is called when a popup uses tabbed behavior.
3933-func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
3933+func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error {
39343934 log := logging.FromContext(ctx)
3935- cleanupUnattachedPopup := func() {
3936- if wv != nil {
3937- wv.Destroy()
3938- }
3939- }
3940-
39413935 wsView := a.workspaceViews[tabID]
39423936 if wsView == nil {
3943- cleanupUnattachedPopup()
39443937 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
3945- return
3938+ return fmt.Errorf("workspace view not found for popup tab %q", tabID)
39463939 }
39473940 if pane == nil {
3948- cleanupUnattachedPopup()
39493941 log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")
3950- return
3942+ return fmt.Errorf("popup pane is nil")
39513943 }
39523944 if wsView.GetPaneView(pane.ID) == nil {
3953- cleanupUnattachedPopup()
39543945 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3955- return
3946+ return fmt.Errorf("pane view not found for popup %q", pane.ID)
39563947 }
39573948 if a.contentCoord == nil {
3958- cleanupUnattachedPopup()
39593949 log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup")
3960- return
3950+ return fmt.Errorf("content coordinator not configured for popup")
39613951 }
39623952
3963- // Register WebView with content coordinator.
3964- a.contentCoord.RegisterPopupWebView(pane.ID, wv)
3965-
3966- // Wrap and attach widget.
3953+ // Wrap and attach before registration so a failed attachment leaves the
3954+ // caller-owned WebView untouched and unregistered.
39673955 widget := a.contentCoord.WrapWidget(ctx, wv)
39683956 if widget == nil {
3969- a.contentCoord.ReleaseWebView(ctx, pane.ID)
39703957 log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")
3971- return
3958+ return fmt.Errorf("wrap popup webview")
39723959 }
39733960 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3974- a.contentCoord.ReleaseWebView(ctx, pane.ID)
39753961 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3976- return
3962+ return fmt.Errorf("set popup webview widget: %w", err)
39773963 }
3964+ a.contentCoord.RegisterPopupWebView(pane.ID, wv)
39783965 log.Debug().
39793966 Str("tab_id", string(tabID)).
39803967 Str("pane_id", string(pane.ID)).
39813968 Msg("popup webview attached to tab")
3969+ return nil
39823970 }
39833971
39843972 // switchWorkspaceView swaps the displayed workspace view for a tab.
@@ -4584,6 +4572,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe
45844572 return nil
45854573 }
45864574
4575+// popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A
4576+// floating pane is mapped through its session's tab, never by parsing PaneID.
4577+func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) {
4578+ for key, session := range a.floatingSessions {
4579+ if session == nil || session.paneID != paneID {
4580+ continue
4581+ }
4582+ if bw := a.browserWindowForTab(key.tabID); bw != nil {
4583+ return bw.id, true
4584+ }
4585+ return "", false
4586+ }
4587+ if bw := a.browserWindowForAnyPane(paneID); bw != nil {
4588+ return bw.id, true
4589+ }
4590+ return "", false
4591+}
4592+
45874593 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
45884594 if session == nil || session.overlay == nil || session.resizeWatcherActive {
45894595 return
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 "unsafe"14 "unsafe"
15 15
16+ "github.com/bnema/dumber/internal/application/dto"
16 "github.com/bnema/dumber/internal/application/port"17 "github.com/bnema/dumber/internal/application/port"
17 "github.com/bnema/dumber/internal/application/usecase"18 "github.com/bnema/dumber/internal/application/usecase"
18 "github.com/bnema/dumber/internal/domain/entity"19 "github.com/bnema/dumber/internal/domain/entity"
@@ -3077,9 +3078,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
3077 }3078 }
3078 })3079 })
3079 // Wire popup tab WebView attachment3080 // Wire popup tab WebView attachment
3080- a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {3081+ a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)
3081- a.attachPopupToTab(ctx, tabID, pane, wv)
3082- })
3083 3082
3084 for _, bw := range a.browserWindows {3083 for _, bw := range a.browserWindows {
3085 a.wireBrowserWindowTabBar(ctx, bw)3084 a.wireBrowserWindowTabBar(ctx, bw)
@@ -3157,13 +3156,14 @@ func (a *App) initCoordinators(ctx context.Context) {
3157 &runtimeCfg.Workspace.BrowsingContexts,3156 &runtimeCfg.Workspace.BrowsingContexts,
3158 a.generateID,3157 a.generateID,
3159 )3158 )
3160- a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {3159+ a.contentCoord.SetPopupSourceHostResolver(func(paneID entity.PaneID) dto.SourceHostKind {
3161- bw := a.browserWindowForAnyPane(paneID)3160+ if a.floatingSessionByPaneID(paneID) != nil {
3162- if bw == nil {3161+ return dto.SourceHostFloating
3163- return "", false
3164 }3162 }
3165- return bw.id, true3163+ return dto.SourceHostWorkspace
3166 })3164 })
3165+ a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane)
3166+ a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow)
3167 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {3167 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
3168 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {3168 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
3169 a.activateBrowserWindow(bw)3169 a.activateBrowserWindow(bw)
@@ -3930,55 +3930,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
3930 3930
3931 // attachPopupToTab attaches a popup WebView to a newly created tab.3931 // attachPopupToTab attaches a popup WebView to a newly created tab.
3932 // This is called when a popup uses tabbed behavior.3932 // This is called when a popup uses tabbed behavior.
3933-func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {3933+func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error {
3934 log := logging.FromContext(ctx)3934 log := logging.FromContext(ctx)
3935- cleanupUnattachedPopup := func() {
3936- if wv != nil {
3937- wv.Destroy()
3938- }
3939- }
3940-
3941 wsView := a.workspaceViews[tabID]3935 wsView := a.workspaceViews[tabID]
3942 if wsView == nil {3936 if wsView == nil {
3943- cleanupUnattachedPopup()
3944 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")3937 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
3945- return3938+ return fmt.Errorf("workspace view not found for popup tab %q", tabID)
3946 }3939 }
3947 if pane == nil {3940 if pane == nil {
3948- cleanupUnattachedPopup()
3949 log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")3941 log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")
3950- return3942+ return fmt.Errorf("popup pane is nil")
3951 }3943 }
3952 if wsView.GetPaneView(pane.ID) == nil {3944 if wsView.GetPaneView(pane.ID) == nil {
3953- cleanupUnattachedPopup()
3954 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")3945 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3955- return3946+ return fmt.Errorf("pane view not found for popup %q", pane.ID)
3956 }3947 }
3957 if a.contentCoord == nil {3948 if a.contentCoord == nil {
3958- cleanupUnattachedPopup()
3959 log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup")3949 log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup")
3960- return3950+ return fmt.Errorf("content coordinator not configured for popup")
3961 }3951 }
3962 3952
3963- // Register WebView with content coordinator.3953+ // Wrap and attach before registration so a failed attachment leaves the
3964- a.contentCoord.RegisterPopupWebView(pane.ID, wv)3954+ // caller-owned WebView untouched and unregistered.
3965-
3966- // Wrap and attach widget.
3967 widget := a.contentCoord.WrapWidget(ctx, wv)3955 widget := a.contentCoord.WrapWidget(ctx, wv)
3968 if widget == nil {3956 if widget == nil {
3969- a.contentCoord.ReleaseWebView(ctx, pane.ID)
3970 log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")3957 log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")
3971- return3958+ return fmt.Errorf("wrap popup webview")
3972 }3959 }
3973 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {3960 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3974- a.contentCoord.ReleaseWebView(ctx, pane.ID)
3975 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")3961 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3976- return3962+ return fmt.Errorf("set popup webview widget: %w", err)
3977 }3963 }
3964+ a.contentCoord.RegisterPopupWebView(pane.ID, wv)
3978 log.Debug().3965 log.Debug().
3979 Str("tab_id", string(tabID)).3966 Str("tab_id", string(tabID)).
3980 Str("pane_id", string(pane.ID)).3967 Str("pane_id", string(pane.ID)).
3981 Msg("popup webview attached to tab")3968 Msg("popup webview attached to tab")
3969+ return nil
3982 }3970 }
3983 3971
3984 // switchWorkspaceView swaps the displayed workspace view for a tab.3972 // switchWorkspaceView swaps the displayed workspace view for a tab.
@@ -4584,6 +4572,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe
4584 return nil4572 return nil
4585 }4573 }
4586 4574
4575+// popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A
4576+// floating pane is mapped through its session's tab, never by parsing PaneID.
4577+func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) {
4578+ for key, session := range a.floatingSessions {
4579+ if session == nil || session.paneID != paneID {
4580+ continue
4581+ }
4582+ if bw := a.browserWindowForTab(key.tabID); bw != nil {
4583+ return bw.id, true
4584+ }
4585+ return "", false
4586+ }
4587+ if bw := a.browserWindowForAnyPane(paneID); bw != nil {
4588+ return bw.id, true
4589+ }
4590+ return "", false
4591+}
4592+
4587 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {4593 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
4588 if session == nil || session.overlay == nil || session.resizeWatcherActive {4594 if session == nil || session.overlay == nil || session.resizeWatcherActive {
4589 return4595 return
M internal/ui/app_browser_launch_test.go +15 -14
@@ -2279,6 +2279,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
22792279 mainWindow: first.mainWindow,
22802280 }
22812281 app.initTabCoordinator(ctx)
2282+ app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
22822283
22832284 err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
22842285 ParentPaneID: secondTab.Workspace.ActivePaneID,
@@ -2538,28 +2539,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f
25382539 func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 }
25392540 func (f *recordingWebView) Destroy() { f.destroyCalls++ }
25402541
2541-func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) {
2542+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) {
25422543 ctx := context.Background()
25432544 popupWV := &recordingWebView{id: 1}
25442545 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
25452546
2546- app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
2547+ err := app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
25472548
2548- if popupWV.destroyCalls != 1 {
2549- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2549+ if err == nil || popupWV.destroyCalls != 0 {
2550+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25502551 }
25512552 }
25522553
2553-func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) {
2554+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) {
25542555 ctx := context.Background()
25552556 popupWV := &recordingWebView{id: 1}
25562557 tabID := entity.TabID("tab-1")
25572558 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
25582559
2559- app.attachPopupToTab(ctx, tabID, nil, popupWV)
2560+ err := app.attachPopupToTab(ctx, tabID, nil, popupWV)
25602561
2561- if popupWV.destroyCalls != 1 {
2562- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2562+ if err == nil || popupWV.destroyCalls != 0 {
2563+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25632564 }
25642565 }
25652566
@@ -2576,13 +2577,13 @@ func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T)
25762577 }
25772578
25782579 popupWV := &recordingWebView{id: 1}
2579- app.attachPopupToTab(ctx, tabID, pane, popupWV)
2580+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
25802581
25812582 if got := contentCoord.GetWebView(pane.ID); got != nil {
25822583 t.Fatalf("popup webview was registered for missing pane view: %v", got)
25832584 }
2584- if popupWV.destroyCalls != 1 {
2585- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2585+ if err == nil || popupWV.destroyCalls != 0 {
2586+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
25862587 }
25872588 }
25882589
@@ -2617,13 +2618,13 @@ func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
26172618 }
26182619 popupWV := &recordingWebView{id: 1}
26192620
2620- app.attachPopupToTab(ctx, tabID, pane, popupWV)
2621+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
26212622
26222623 if got := contentCoord.GetWebView(pane.ID); got != nil {
26232624 t.Fatalf("popup webview registration remained after wrap failure: %v", got)
26242625 }
2625- if popupWV.destroyCalls != 1 {
2626- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2626+ if err == nil || popupWV.destroyCalls != 0 {
2627+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
26272628 }
26282629 }
26292630
@@ -2279,6 +2279,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
2279 mainWindow: first.mainWindow,2279 mainWindow: first.mainWindow,
2280 }2280 }
2281 app.initTabCoordinator(ctx)2281 app.initTabCoordinator(ctx)
2282+ app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
2282 2283
2283 err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{2284 err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
2284 ParentPaneID: secondTab.Workspace.ActivePaneID,2285 ParentPaneID: secondTab.Workspace.ActivePaneID,
@@ -2538,28 +2539,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f
2538 func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 }2539 func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 }
2539 func (f *recordingWebView) Destroy() { f.destroyCalls++ }2540 func (f *recordingWebView) Destroy() { f.destroyCalls++ }
2540 2541
2541-func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) {2542+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) {
2542 ctx := context.Background()2543 ctx := context.Background()
2543 popupWV := &recordingWebView{id: 1}2544 popupWV := &recordingWebView{id: 1}
2544 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}2545 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
2545 2546
2546- app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)2547+ err := app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
2547 2548
2548- if popupWV.destroyCalls != 1 {2549+ if err == nil || popupWV.destroyCalls != 0 {
2549- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)2550+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2550 }2551 }
2551 }2552 }
2552 2553
2553-func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) {2554+func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) {
2554 ctx := context.Background()2555 ctx := context.Background()
2555 popupWV := &recordingWebView{id: 1}2556 popupWV := &recordingWebView{id: 1}
2556 tabID := entity.TabID("tab-1")2557 tabID := entity.TabID("tab-1")
2557 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}2558 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
2558 2559
2559- app.attachPopupToTab(ctx, tabID, nil, popupWV)2560+ err := app.attachPopupToTab(ctx, tabID, nil, popupWV)
2560 2561
2561- if popupWV.destroyCalls != 1 {2562+ if err == nil || popupWV.destroyCalls != 0 {
2562- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)2563+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2563 }2564 }
2564 }2565 }
2565 2566
@@ -2576,13 +2577,13 @@ func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T)
2576 }2577 }
2577 2578
2578 popupWV := &recordingWebView{id: 1}2579 popupWV := &recordingWebView{id: 1}
2579- app.attachPopupToTab(ctx, tabID, pane, popupWV)2580+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
2580 2581
2581 if got := contentCoord.GetWebView(pane.ID); got != nil {2582 if got := contentCoord.GetWebView(pane.ID); got != nil {
2582 t.Fatalf("popup webview was registered for missing pane view: %v", got)2583 t.Fatalf("popup webview was registered for missing pane view: %v", got)
2583 }2584 }
2584- if popupWV.destroyCalls != 1 {2585+ if err == nil || popupWV.destroyCalls != 0 {
2585- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)2586+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2586 }2587 }
2587 }2588 }
2588 2589
@@ -2617,13 +2618,13 @@ func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
2617 }2618 }
2618 popupWV := &recordingWebView{id: 1}2619 popupWV := &recordingWebView{id: 1}
2619 2620
2620- app.attachPopupToTab(ctx, tabID, pane, popupWV)2621+ err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
2621 2622
2622 if got := contentCoord.GetWebView(pane.ID); got != nil {2623 if got := contentCoord.GetWebView(pane.ID); got != nil {
2623 t.Fatalf("popup webview registration remained after wrap failure: %v", got)2624 t.Fatalf("popup webview registration remained after wrap failure: %v", got)
2624 }2625 }
2625- if popupWV.destroyCalls != 1 {2626+ if err == nil || popupWV.destroyCalls != 0 {
2626- t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)2627+ t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2627 }2628 }
2628 }2629 }
2629 2630
A internal/ui/browser_popup_window.go +58 -0
@@ -0,0 +1,58 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/coordinator/content"
10+)
11+
12+// openPopupBrowserWindow adopts the exact related WebView created for a
13+// browsing-context request into a complete Dumber browser window.
14+func (a *App) openPopupBrowserWindow(ctx context.Context, input content.BrowserWindowInput) (content.BrowserWindowResult, error) {
15+ if input.PopupPane == nil || input.PopupWebView == nil {
16+ return content.BrowserWindowResult{}, fmt.Errorf("popup browser window requires pane and webview")
17+ }
18+ bw, err := a.createEmptyBrowserWindow(ctx)
19+ if err != nil {
20+ return content.BrowserWindowResult{}, err
21+ }
22+ tab, err := a.tabCoord.CreateWithPane(
23+ ctx,
24+ a.ensureTabTargetForBrowserWindow(bw),
25+ input.PopupPane,
26+ input.PopupWebView,
27+ input.TargetURI,
28+ )
29+ if err != nil {
30+ a.cleanupEjectTargetWindow(bw)
31+ return content.BrowserWindowResult{}, err
32+ }
33+ a.setBrowserWindowForTab(tab.ID, bw)
34+ a.activateBrowserWindow(bw)
35+
36+ show := func() {
37+ if bw.mainWindow != nil {
38+ bw.mainWindow.Show()
39+ }
40+ }
41+ if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok {
42+ lifecycle.SetOnClose(func() { a.closeAdoptedPopupTab(context.Background(), bw, tab.ID) })
43+ if !input.Ready {
44+ lifecycle.SetOnReadyToShow(show)
45+ return content.BrowserWindowResult{WindowID: bw.id}, nil
46+ }
47+ }
48+ show()
49+ return content.BrowserWindowResult{WindowID: bw.id}, nil
50+}
51+
52+func (a *App) closeAdoptedPopupTab(ctx context.Context, bw *browserWindow, tabID entity.TabID) {
53+ if a == nil || bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
54+ return
55+ }
56+ bw.tabs.SetActive(tabID)
57+ _ = a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
58+}
@@ -0,0 +1,58 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/coordinator/content"
10+)
11+
12+// openPopupBrowserWindow adopts the exact related WebView created for a
13+// browsing-context request into a complete Dumber browser window.
14+func (a *App) openPopupBrowserWindow(ctx context.Context, input content.BrowserWindowInput) (content.BrowserWindowResult, error) {
15+ if input.PopupPane == nil || input.PopupWebView == nil {
16+ return content.BrowserWindowResult{}, fmt.Errorf("popup browser window requires pane and webview")
17+ }
18+ bw, err := a.createEmptyBrowserWindow(ctx)
19+ if err != nil {
20+ return content.BrowserWindowResult{}, err
21+ }
22+ tab, err := a.tabCoord.CreateWithPane(
23+ ctx,
24+ a.ensureTabTargetForBrowserWindow(bw),
25+ input.PopupPane,
26+ input.PopupWebView,
27+ input.TargetURI,
28+ )
29+ if err != nil {
30+ a.cleanupEjectTargetWindow(bw)
31+ return content.BrowserWindowResult{}, err
32+ }
33+ a.setBrowserWindowForTab(tab.ID, bw)
34+ a.activateBrowserWindow(bw)
35+
36+ show := func() {
37+ if bw.mainWindow != nil {
38+ bw.mainWindow.Show()
39+ }
40+ }
41+ if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok {
42+ lifecycle.SetOnClose(func() { a.closeAdoptedPopupTab(context.Background(), bw, tab.ID) })
43+ if !input.Ready {
44+ lifecycle.SetOnReadyToShow(show)
45+ return content.BrowserWindowResult{WindowID: bw.id}, nil
46+ }
47+ }
48+ show()
49+ return content.BrowserWindowResult{WindowID: bw.id}, nil
50+}
51+
52+func (a *App) closeAdoptedPopupTab(ctx context.Context, bw *browserWindow, tabID entity.TabID) {
53+ if a == nil || bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
54+ return
55+ }
56+ bw.tabs.SetActive(tabID)
57+ _ = a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
58+}
A internal/ui/browser_popup_window_test.go +110 -0
@@ -0,0 +1,110 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/coordinator"
17+ "github.com/bnema/dumber/internal/ui/coordinator/content"
18+)
19+
20+func newPopupAdoptionApp(t *testing.T, attach func(context.Context, entity.TabID, *entity.Pane, port.WebView) error) (*App, *browserWindow) {
21+ t.Helper()
22+ bw := &browserWindow{id: "detached-window", tabs: entity.NewTabList()}
23+ app := &App{
24+ tabs: entity.NewTabList(), tabsUC: usecase.NewManageTabsUseCase(func() string { return "adopted-tab" }, nil),
25+ browserWindows: map[string]*browserWindow{}, windowForTab: map[entity.TabID]*browserWindow{},
26+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
27+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) { return bw, nil },
28+ }
29+ app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC, Tabs: app.tabs})
30+ app.tabCoord.SetOnAttachPopupToTab(attach)
31+ app.tabCoord.SetOnTabClosed(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
32+ delete(app.windowForTab, tab.ID)
33+ delete(app.workspaceViews, tab.ID)
34+ app.tabs.Remove(tab.ID)
35+ })
36+ return app, bw
37+}
38+
39+type popupLifecycleWebView struct {
40+ *portmocks.MockWebView
41+ onReady func()
42+ onClose func()
43+ showCalls int
44+}
45+
46+func (w *popupLifecycleWebView) SetOnReadyToShow(fn func()) { w.onReady = fn }
47+func (w *popupLifecycleWebView) SetOnClose(fn func()) { w.onClose = fn }
48+func (w *popupLifecycleWebView) Show() { w.showCalls++ }
49+func (w *popupLifecycleWebView) PrimePopupNavigation(string) {}
50+
51+func TestOpenPopupBrowserWindowAdoptsProvidedWebView(t *testing.T) {
52+ wv := portmocks.NewMockWebView(t)
53+ var adopted port.WebView
54+ app, bw := newPopupAdoptionApp(t, func(_ context.Context, _ entity.TabID, _ *entity.Pane, got port.WebView) error {
55+ adopted = got
56+ return nil
57+ })
58+
59+ result, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
60+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true,
61+ })
62+
63+ require.NoError(t, err)
64+ assert.Same(t, wv, adopted)
65+ assert.Equal(t, bw.id, result.WindowID)
66+ assert.Same(t, bw, app.windowForTab["adopted-tab"])
67+}
68+
69+func TestOpenPopupBrowserWindowWaitsForReadySignalWhenNotReady(t *testing.T) {
70+ wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)}
71+ app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
72+
73+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
74+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: false,
75+ })
76+
77+ require.NoError(t, err)
78+ require.NotNil(t, wv.onReady)
79+ assert.NotNil(t, wv.onClose)
80+}
81+
82+func TestOpenPopupBrowserWindowDoesNotWaitForReadyWhenAlreadyReady(t *testing.T) {
83+ wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)}
84+ app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
85+
86+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
87+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: true,
88+ })
89+
90+ require.NoError(t, err)
91+ assert.Nil(t, wv.onReady)
92+ assert.NotNil(t, wv.onClose)
93+}
94+
95+func TestOpenPopupBrowserWindowRollsBackShellWhenCreateWithPaneFails(t *testing.T) {
96+ wv := portmocks.NewMockWebView(t)
97+ app, bw := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error {
98+ return errors.New("attachment failed")
99+ })
100+
101+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
102+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true,
103+ })
104+
105+ require.Error(t, err)
106+ assert.False(t, app.hasBrowserWindow(bw))
107+ assert.Zero(t, bw.tabs.Count())
108+ assert.Empty(t, app.windowForTab)
109+ assert.Empty(t, app.workspaceViews)
110+}
@@ -0,0 +1,110 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/coordinator"
17+ "github.com/bnema/dumber/internal/ui/coordinator/content"
18+)
19+
20+func newPopupAdoptionApp(t *testing.T, attach func(context.Context, entity.TabID, *entity.Pane, port.WebView) error) (*App, *browserWindow) {
21+ t.Helper()
22+ bw := &browserWindow{id: "detached-window", tabs: entity.NewTabList()}
23+ app := &App{
24+ tabs: entity.NewTabList(), tabsUC: usecase.NewManageTabsUseCase(func() string { return "adopted-tab" }, nil),
25+ browserWindows: map[string]*browserWindow{}, windowForTab: map[entity.TabID]*browserWindow{},
26+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
27+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) { return bw, nil },
28+ }
29+ app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC, Tabs: app.tabs})
30+ app.tabCoord.SetOnAttachPopupToTab(attach)
31+ app.tabCoord.SetOnTabClosed(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
32+ delete(app.windowForTab, tab.ID)
33+ delete(app.workspaceViews, tab.ID)
34+ app.tabs.Remove(tab.ID)
35+ })
36+ return app, bw
37+}
38+
39+type popupLifecycleWebView struct {
40+ *portmocks.MockWebView
41+ onReady func()
42+ onClose func()
43+ showCalls int
44+}
45+
46+func (w *popupLifecycleWebView) SetOnReadyToShow(fn func()) { w.onReady = fn }
47+func (w *popupLifecycleWebView) SetOnClose(fn func()) { w.onClose = fn }
48+func (w *popupLifecycleWebView) Show() { w.showCalls++ }
49+func (w *popupLifecycleWebView) PrimePopupNavigation(string) {}
50+
51+func TestOpenPopupBrowserWindowAdoptsProvidedWebView(t *testing.T) {
52+ wv := portmocks.NewMockWebView(t)
53+ var adopted port.WebView
54+ app, bw := newPopupAdoptionApp(t, func(_ context.Context, _ entity.TabID, _ *entity.Pane, got port.WebView) error {
55+ adopted = got
56+ return nil
57+ })
58+
59+ result, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
60+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true,
61+ })
62+
63+ require.NoError(t, err)
64+ assert.Same(t, wv, adopted)
65+ assert.Equal(t, bw.id, result.WindowID)
66+ assert.Same(t, bw, app.windowForTab["adopted-tab"])
67+}
68+
69+func TestOpenPopupBrowserWindowWaitsForReadySignalWhenNotReady(t *testing.T) {
70+ wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)}
71+ app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
72+
73+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
74+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: false,
75+ })
76+
77+ require.NoError(t, err)
78+ require.NotNil(t, wv.onReady)
79+ assert.NotNil(t, wv.onClose)
80+}
81+
82+func TestOpenPopupBrowserWindowDoesNotWaitForReadyWhenAlreadyReady(t *testing.T) {
83+ wv := &popupLifecycleWebView{MockWebView: portmocks.NewMockWebView(t)}
84+ app, _ := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
85+
86+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
87+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, Ready: true,
88+ })
89+
90+ require.NoError(t, err)
91+ assert.Nil(t, wv.onReady)
92+ assert.NotNil(t, wv.onClose)
93+}
94+
95+func TestOpenPopupBrowserWindowRollsBackShellWhenCreateWithPaneFails(t *testing.T) {
96+ wv := portmocks.NewMockWebView(t)
97+ app, bw := newPopupAdoptionApp(t, func(context.Context, entity.TabID, *entity.Pane, port.WebView) error {
98+ return errors.New("attachment failed")
99+ })
100+
101+ _, err := app.openPopupBrowserWindow(context.Background(), content.BrowserWindowInput{
102+ PopupPane: entity.NewPane("popup-pane"), PopupWebView: wv, TargetURI: "https://example.com", Ready: true,
103+ })
104+
105+ require.Error(t, err)
106+ assert.False(t, app.hasBrowserWindow(bw))
107+ assert.Zero(t, bw.tabs.Count())
108+ assert.Empty(t, app.windowForTab)
109+ assert.Empty(t, app.workspaceViews)
110+}
M internal/ui/coordinator/content/browsing_context_policy.go +45 -10
@@ -23,39 +23,68 @@ func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedCont
2323 case strings.TrimSpace(req.TargetURI) == "":
2424 return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")
2525 case req.AuthIntent:
26- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup, "authentication intent requires a related native popup")
26+ return completeHostDecision(
27+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup,
28+ "authentication intent requires a related native popup",
29+ )
2730 case req.RequiresNativeOpener:
28- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired, "native opener relationship is required")
31+ return completeHostDecision(
32+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired,
33+ "native opener relationship is required",
34+ )
2935 case isAmbiguousNativeBrowsingContext(req):
30- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative, "ambiguous opener-coupled request prefers a related native popup")
36+ return completeHostDecision(
37+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative,
38+ "ambiguous opener-coupled request prefers a related native popup",
39+ )
3140 }
3241
3342 name := reusableBrowsingContextName(req.TargetFrameName)
3443 if namedContextExists && name != "" && !req.NoJavaScriptAccess {
3544 decision.ReuseContextName = name
3645 decision.BrowsingContextName = name
37- return completeHostDecision(decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse, "reuse live named browsing context")
46+ return completeHostDecision(
47+ decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse,
48+ "reuse live named browsing context",
49+ )
3850 }
3951 decision.BrowsingContextName = name
4052
4153 if req.SourceHost == dto.SourceHostFloating {
4254 return decideFloatingHost(decision, req)
4355 }
44- return completeHostDecision(decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane, "workspace request uses pane hosting")
56+ return completeHostDecision(
57+ decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane,
58+ "workspace request uses pane hosting",
59+ )
4560 }
4661
4762 func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision {
4863 switch {
4964 case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage:
50- return completeHostDecision(decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending, "popup features unavailable until ready-to-show")
65+ return completeHostDecision(
66+ decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending,
67+ "popup features unavailable until ready-to-show",
68+ )
5169 case req.PopupFeatures.RequestsPopupHost():
52- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, "explicit popup features requested")
70+ return completeHostDecision(
71+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,
72+ "explicit popup features requested",
73+ )
5374 default:
54- return completeHostDecision(decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, "floating request opens a detached browser window")
75+ return completeHostDecision(
76+ decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,
77+ "floating request opens a detached browser window",
78+ )
5579 }
5680 }
5781
58-func completeHostDecision(decision dto.HostDecision, kind dto.HostDecisionKind, code dto.HostDecisionReasonCode, reason string) dto.HostDecision {
82+func completeHostDecision(
83+ decision dto.HostDecision,
84+ kind dto.HostDecisionKind,
85+ code dto.HostDecisionReasonCode,
86+ reason string,
87+) dto.HostDecision {
5988 decision.Kind = kind
6089 decision.ReasonCode = code
6190 decision.Reason = reason
@@ -72,7 +101,13 @@ func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContex
72101 Msg("browsing context host decided")
73102 }
74103
75-func logBrowsingContextFailure(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision, code dto.BrowsingContextFailureCode, err error) {
104+func logBrowsingContextFailure(
105+ logger zerolog.Logger,
106+ req dto.NewBrowsingContextRequest,
107+ decision dto.HostDecision,
108+ code dto.BrowsingContextFailureCode,
109+ err error,
110+) {
76111 logger.Error().Err(err).
77112 Str("engine", string(req.Engine)).
78113 Str("source_host", string(req.SourceHost)).
@@ -23,39 +23,68 @@ func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedCont
23 case strings.TrimSpace(req.TargetURI) == "":23 case strings.TrimSpace(req.TargetURI) == "":
24 return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")24 return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")
25 case req.AuthIntent:25 case req.AuthIntent:
26- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup, "authentication intent requires a related native popup")26+ return completeHostDecision(
27+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup,
28+ "authentication intent requires a related native popup",
29+ )
27 case req.RequiresNativeOpener:30 case req.RequiresNativeOpener:
28- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired, "native opener relationship is required")31+ return completeHostDecision(
32+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired,
33+ "native opener relationship is required",
34+ )
29 case isAmbiguousNativeBrowsingContext(req):35 case isAmbiguousNativeBrowsingContext(req):
30- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative, "ambiguous opener-coupled request prefers a related native popup")36+ return completeHostDecision(
37+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative,
38+ "ambiguous opener-coupled request prefers a related native popup",
39+ )
31 }40 }
32 41
33 name := reusableBrowsingContextName(req.TargetFrameName)42 name := reusableBrowsingContextName(req.TargetFrameName)
34 if namedContextExists && name != "" && !req.NoJavaScriptAccess {43 if namedContextExists && name != "" && !req.NoJavaScriptAccess {
35 decision.ReuseContextName = name44 decision.ReuseContextName = name
36 decision.BrowsingContextName = name45 decision.BrowsingContextName = name
37- return completeHostDecision(decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse, "reuse live named browsing context")46+ return completeHostDecision(
47+ decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse,
48+ "reuse live named browsing context",
49+ )
38 }50 }
39 decision.BrowsingContextName = name51 decision.BrowsingContextName = name
40 52
41 if req.SourceHost == dto.SourceHostFloating {53 if req.SourceHost == dto.SourceHostFloating {
42 return decideFloatingHost(decision, req)54 return decideFloatingHost(decision, req)
43 }55 }
44- return completeHostDecision(decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane, "workspace request uses pane hosting")56+ return completeHostDecision(
57+ decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane,
58+ "workspace request uses pane hosting",
59+ )
45 }60 }
46 61
47 func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision {62 func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision {
48 switch {63 switch {
49 case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage:64 case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage:
50- return completeHostDecision(decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending, "popup features unavailable until ready-to-show")65+ return completeHostDecision(
66+ decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending,
67+ "popup features unavailable until ready-to-show",
68+ )
51 case req.PopupFeatures.RequestsPopupHost():69 case req.PopupFeatures.RequestsPopupHost():
52- return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, "explicit popup features requested")70+ return completeHostDecision(
71+ decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,
72+ "explicit popup features requested",
73+ )
53 default:74 default:
54- return completeHostDecision(decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, "floating request opens a detached browser window")75+ return completeHostDecision(
76+ decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,
77+ "floating request opens a detached browser window",
78+ )
55 }79 }
56 }80 }
57 81
58-func completeHostDecision(decision dto.HostDecision, kind dto.HostDecisionKind, code dto.HostDecisionReasonCode, reason string) dto.HostDecision {82+func completeHostDecision(
83+ decision dto.HostDecision,
84+ kind dto.HostDecisionKind,
85+ code dto.HostDecisionReasonCode,
86+ reason string,
87+) dto.HostDecision {
59 decision.Kind = kind88 decision.Kind = kind
60 decision.ReasonCode = code89 decision.ReasonCode = code
61 decision.Reason = reason90 decision.Reason = reason
@@ -72,7 +101,13 @@ func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContex
72 Msg("browsing context host decided")101 Msg("browsing context host decided")
73 }102 }
74 103
75-func logBrowsingContextFailure(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision, code dto.BrowsingContextFailureCode, err error) {104+func logBrowsingContextFailure(
105+ logger zerolog.Logger,
106+ req dto.NewBrowsingContextRequest,
107+ decision dto.HostDecision,
108+ code dto.BrowsingContextFailureCode,
109+ err error,
110+) {
76 logger.Error().Err(err).111 logger.Error().Err(err).
77 Str("engine", string(req.Engine)).112 Str("engine", string(req.Engine)).
78 Str("source_host", string(req.SourceHost)).113 Str("source_host", string(req.SourceHost)).
M internal/ui/coordinator/content/lifecycle_test.go +1 -1
@@ -223,7 +223,7 @@ func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *test
223223 return "", false
224224 })
225225 c.webViews[paneID] = wv
226- c.popups.namedContexts.Register(windowID, "named-popup", paneID, wv.ID())
226+ c.popups.namedContexts.Register(windowID, windowID, "named-popup", paneID, wv.ID())
227227
228228 _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
229229 require.True(t, okBefore)
@@ -223,7 +223,7 @@ func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *test
223 return "", false223 return "", false
224 })224 })
225 c.webViews[paneID] = wv225 c.webViews[paneID] = wv
226- c.popups.namedContexts.Register(windowID, "named-popup", paneID, wv.ID())226+ c.popups.namedContexts.Register(windowID, windowID, "named-popup", paneID, wv.ID())
227 227
228 _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)228 _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
229 require.True(t, okBefore)229 require.True(t, okBefore)
M internal/ui/coordinator/content/named_context_registry.go +13 -13
@@ -8,13 +8,14 @@ import (
88 )
99
1010 type namedBrowsingContextKey struct {
11- WindowID string
12- Name string
11+ OwnerWindowID string
12+ Name string
1313 }
1414
1515 type namedBrowsingContextState struct {
16- PaneID entity.PaneID
17- WebViewID port.WebViewID
16+ PaneID entity.PaneID
17+ WebViewID port.WebViewID
18+ HostWindowID string
1819 }
1920
2021 type namedBrowsingContextRegistry struct {
@@ -28,14 +29,13 @@ func newNamedBrowsingContextRegistry() *namedBrowsingContextRegistry {
2829 }
2930 }
3031
31-func (r *namedBrowsingContextRegistry) Register(windowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
32- if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 {
32+func (r *namedBrowsingContextRegistry) Register(ownerWindowID, hostWindowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
33+ if r == nil || ownerWindowID == "" || hostWindowID == "" || name == "" || paneID == "" || webViewID == 0 {
3334 return
3435 }
3536 r.mu.Lock()
36- r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{
37- PaneID: paneID,
38- WebViewID: webViewID,
37+ r.contexts[namedBrowsingContextKey{OwnerWindowID: ownerWindowID, Name: name}] = namedBrowsingContextState{
38+ PaneID: paneID, WebViewID: webViewID, HostWindowID: hostWindowID,
3939 }
4040 r.mu.Unlock()
4141 }
@@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
4949 return namedBrowsingContextState{}, nil, false
5050 }
5151
52- key := namedBrowsingContextKey{WindowID: windowID, Name: name}
52+ key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name}
5353 r.mu.RLock()
5454 state, ok := r.contexts[key]
5555 r.mu.RUnlock()
@@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
5858 }
5959
6060 currentWindowID, ok := resolveWindowID(state.PaneID)
61- if !ok || currentWindowID != windowID {
61+ if !ok || currentWindowID != state.HostWindowID {
6262 r.deleteIfStateMatches(key, state)
6363 return namedBrowsingContextState{}, nil, false
6464 }
@@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) {
114114 return
115115 }
116116 r.mu.Lock()
117- for key := range r.contexts {
118- if key.WindowID == windowID {
117+ for key, state := range r.contexts {
118+ if key.OwnerWindowID == windowID || state.HostWindowID == windowID {
119119 delete(r.contexts, key)
120120 }
121121 }
@@ -8,13 +8,14 @@ import (
8 )8 )
9 9
10 type namedBrowsingContextKey struct {10 type namedBrowsingContextKey struct {
11- WindowID string11+ OwnerWindowID string
12- Name string12+ Name string
13 }13 }
14 14
15 type namedBrowsingContextState struct {15 type namedBrowsingContextState struct {
16- PaneID entity.PaneID16+ PaneID entity.PaneID
17- WebViewID port.WebViewID17+ WebViewID port.WebViewID
18+ HostWindowID string
18 }19 }
19 20
20 type namedBrowsingContextRegistry struct {21 type namedBrowsingContextRegistry struct {
@@ -28,14 +29,13 @@ func newNamedBrowsingContextRegistry() *namedBrowsingContextRegistry {
28 }29 }
29 }30 }
30 31
31-func (r *namedBrowsingContextRegistry) Register(windowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {32+func (r *namedBrowsingContextRegistry) Register(ownerWindowID, hostWindowID, name string, paneID entity.PaneID, webViewID port.WebViewID) {
32- if r == nil || windowID == "" || name == "" || paneID == "" || webViewID == 0 {33+ if r == nil || ownerWindowID == "" || hostWindowID == "" || name == "" || paneID == "" || webViewID == 0 {
33 return34 return
34 }35 }
35 r.mu.Lock()36 r.mu.Lock()
36- r.contexts[namedBrowsingContextKey{WindowID: windowID, Name: name}] = namedBrowsingContextState{37+ r.contexts[namedBrowsingContextKey{OwnerWindowID: ownerWindowID, Name: name}] = namedBrowsingContextState{
37- PaneID: paneID,38+ PaneID: paneID, WebViewID: webViewID, HostWindowID: hostWindowID,
38- WebViewID: webViewID,
39 }39 }
40 r.mu.Unlock()40 r.mu.Unlock()
41 }41 }
@@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
49 return namedBrowsingContextState{}, nil, false49 return namedBrowsingContextState{}, nil, false
50 }50 }
51 51
52- key := namedBrowsingContextKey{WindowID: windowID, Name: name}52+ key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name}
53 r.mu.RLock()53 r.mu.RLock()
54 state, ok := r.contexts[key]54 state, ok := r.contexts[key]
55 r.mu.RUnlock()55 r.mu.RUnlock()
@@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup(
58 }58 }
59 59
60 currentWindowID, ok := resolveWindowID(state.PaneID)60 currentWindowID, ok := resolveWindowID(state.PaneID)
61- if !ok || currentWindowID != windowID {61+ if !ok || currentWindowID != state.HostWindowID {
62 r.deleteIfStateMatches(key, state)62 r.deleteIfStateMatches(key, state)
63 return namedBrowsingContextState{}, nil, false63 return namedBrowsingContextState{}, nil, false
64 }64 }
@@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) {
114 return114 return
115 }115 }
116 r.mu.Lock()116 r.mu.Lock()
117- for key := range r.contexts {117+ for key, state := range r.contexts {
118- if key.WindowID == windowID {118+ if key.OwnerWindowID == windowID || state.HostWindowID == windowID {
119 delete(r.contexts, key)119 delete(r.contexts, key)
120 }120 }
121 }121 }
M internal/ui/coordinator/content/named_context_registry_test.go +37 -7
@@ -15,7 +15,7 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
1515 reg := newNamedBrowsingContextRegistry()
1616 paneID := entity.PaneID("pane-1")
1717 webViewID := port.WebViewID(41)
18- reg.Register("window-1", "shared-pane", paneID, webViewID)
18+ reg.Register("window-1", "window-1", "shared-pane", paneID, webViewID)
1919
2020 wv := mocks.NewMockWebView(t)
2121 wv.EXPECT().IsDestroyed().Return(false).Once()
@@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
3939 assert.Same(t, wv, gotWV)
4040 }
4141
42+func TestNamedBrowsingContextRegistry_LookupUsesOwnerScopeAndDetachedHostLiveness(t *testing.T) {
43+ t.Parallel()
44+ reg := newNamedBrowsingContextRegistry()
45+ paneID := entity.PaneID("detached-pane")
46+ webViewID := port.WebViewID(51)
47+ reg.Register("owner-window", "host-window", "named", paneID, webViewID)
48+ wv := mocks.NewMockWebView(t)
49+ wv.EXPECT().IsDestroyed().Return(false).Once()
50+ wv.EXPECT().ID().Return(webViewID).Once()
51+
52+ state, got, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return wv }, func(entity.PaneID) (string, bool) {
53+ return "host-window", true
54+ })
55+
56+ assert.True(t, ok)
57+ assert.Equal(t, "host-window", state.HostWindowID)
58+ assert.Same(t, wv, got)
59+}
60+
61+func TestNamedBrowsingContextRegistry_UnregisterWindowRemovesOwnerAndHostEntries(t *testing.T) {
62+ t.Parallel()
63+ for _, closedWindow := range []string{"owner-window", "host-window"} {
64+ reg := newNamedBrowsingContextRegistry()
65+ reg.Register("owner-window", "host-window", "named", "detached-pane", 51)
66+ reg.UnregisterWindow(closedWindow)
67+ _, _, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return nil }, func(entity.PaneID) (string, bool) { return "host-window", true })
68+ assert.False(t, ok, "closing %s must unregister context", closedWindow)
69+ }
70+}
71+
4272 func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) {
4373 t.Parallel()
4474
4575 reg := newNamedBrowsingContextRegistry()
46- reg.Register("window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41))
76+ reg.Register("window-1", "window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41))
4777
4878 _, _, ok := reg.Lookup(
4979 "window-2",
@@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi
6090
6191 reg := newNamedBrowsingContextRegistry()
6292 paneID := entity.PaneID("pane-1")
63- reg.Register("window-1", "shared-pane", paneID, port.WebViewID(41))
93+ reg.Register("window-1", "window-1", "shared-pane", paneID, port.WebViewID(41))
6494
6595 _, _, ok := reg.Lookup(
6696 "window-1",
@@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
86116 oldPaneID := entity.PaneID("pane-old")
87117 newPaneID := entity.PaneID("pane-new")
88118 newWebViewID := port.WebViewID(42)
89- reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))
119+ reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41))
90120
91121 _, _, ok := reg.Lookup(
92122 "window-1",
@@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
94124 func(entity.PaneID) port.WebView { return nil },
95125 func(gotPaneID entity.PaneID) (string, bool) {
96126 assert.Equal(t, oldPaneID, gotPaneID)
97- reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)
127+ reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID)
98128 return "window-2", true
99129 },
100130 )
@@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS
128158 oldPaneID := entity.PaneID("pane-old")
129159 newPaneID := entity.PaneID("pane-new")
130160 newWebViewID := port.WebViewID(42)
131- reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))
161+ reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41))
132162
133163 _, _, ok := reg.Lookup(
134164 "window-1",
135165 "shared-pane",
136166 func(gotPaneID entity.PaneID) port.WebView {
137167 assert.Equal(t, oldPaneID, gotPaneID)
138- reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)
168+ reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID)
139169 return nil
140170 },
141171 func(gotPaneID entity.PaneID) (string, bool) {
@@ -15,7 +15,7 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
15 reg := newNamedBrowsingContextRegistry()15 reg := newNamedBrowsingContextRegistry()
16 paneID := entity.PaneID("pane-1")16 paneID := entity.PaneID("pane-1")
17 webViewID := port.WebViewID(41)17 webViewID := port.WebViewID(41)
18- reg.Register("window-1", "shared-pane", paneID, webViewID)18+ reg.Register("window-1", "window-1", "shared-pane", paneID, webViewID)
19 19
20 wv := mocks.NewMockWebView(t)20 wv := mocks.NewMockWebView(t)
21 wv.EXPECT().IsDestroyed().Return(false).Once()21 wv.EXPECT().IsDestroyed().Return(false).Once()
@@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T)
39 assert.Same(t, wv, gotWV)39 assert.Same(t, wv, gotWV)
40 }40 }
41 41
42+func TestNamedBrowsingContextRegistry_LookupUsesOwnerScopeAndDetachedHostLiveness(t *testing.T) {
43+ t.Parallel()
44+ reg := newNamedBrowsingContextRegistry()
45+ paneID := entity.PaneID("detached-pane")
46+ webViewID := port.WebViewID(51)
47+ reg.Register("owner-window", "host-window", "named", paneID, webViewID)
48+ wv := mocks.NewMockWebView(t)
49+ wv.EXPECT().IsDestroyed().Return(false).Once()
50+ wv.EXPECT().ID().Return(webViewID).Once()
51+
52+ state, got, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return wv }, func(entity.PaneID) (string, bool) {
53+ return "host-window", true
54+ })
55+
56+ assert.True(t, ok)
57+ assert.Equal(t, "host-window", state.HostWindowID)
58+ assert.Same(t, wv, got)
59+}
60+
61+func TestNamedBrowsingContextRegistry_UnregisterWindowRemovesOwnerAndHostEntries(t *testing.T) {
62+ t.Parallel()
63+ for _, closedWindow := range []string{"owner-window", "host-window"} {
64+ reg := newNamedBrowsingContextRegistry()
65+ reg.Register("owner-window", "host-window", "named", "detached-pane", 51)
66+ reg.UnregisterWindow(closedWindow)
67+ _, _, ok := reg.Lookup("owner-window", "named", func(entity.PaneID) port.WebView { return nil }, func(entity.PaneID) (string, bool) { return "host-window", true })
68+ assert.False(t, ok, "closing %s must unregister context", closedWindow)
69+ }
70+}
71+
42 func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) {72 func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) {
43 t.Parallel()73 t.Parallel()
44 74
45 reg := newNamedBrowsingContextRegistry()75 reg := newNamedBrowsingContextRegistry()
46- reg.Register("window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41))76+ reg.Register("window-1", "window-1", "shared-pane", entity.PaneID("pane-1"), port.WebViewID(41))
47 77
48 _, _, ok := reg.Lookup(78 _, _, ok := reg.Lookup(
49 "window-2",79 "window-2",
@@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi
60 90
61 reg := newNamedBrowsingContextRegistry()91 reg := newNamedBrowsingContextRegistry()
62 paneID := entity.PaneID("pane-1")92 paneID := entity.PaneID("pane-1")
63- reg.Register("window-1", "shared-pane", paneID, port.WebViewID(41))93+ reg.Register("window-1", "window-1", "shared-pane", paneID, port.WebViewID(41))
64 94
65 _, _, ok := reg.Lookup(95 _, _, ok := reg.Lookup(
66 "window-1",96 "window-1",
@@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
86 oldPaneID := entity.PaneID("pane-old")116 oldPaneID := entity.PaneID("pane-old")
87 newPaneID := entity.PaneID("pane-new")117 newPaneID := entity.PaneID("pane-new")
88 newWebViewID := port.WebViewID(42)118 newWebViewID := port.WebViewID(42)
89- reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))119+ reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41))
90 120
91 _, _, ok := reg.Lookup(121 _, _, ok := reg.Lookup(
92 "window-1",122 "window-1",
@@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW
94 func(entity.PaneID) port.WebView { return nil },124 func(entity.PaneID) port.WebView { return nil },
95 func(gotPaneID entity.PaneID) (string, bool) {125 func(gotPaneID entity.PaneID) (string, bool) {
96 assert.Equal(t, oldPaneID, gotPaneID)126 assert.Equal(t, oldPaneID, gotPaneID)
97- reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)127+ reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID)
98 return "window-2", true128 return "window-2", true
99 },129 },
100 )130 )
@@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS
128 oldPaneID := entity.PaneID("pane-old")158 oldPaneID := entity.PaneID("pane-old")
129 newPaneID := entity.PaneID("pane-new")159 newPaneID := entity.PaneID("pane-new")
130 newWebViewID := port.WebViewID(42)160 newWebViewID := port.WebViewID(42)
131- reg.Register("window-1", "shared-pane", oldPaneID, port.WebViewID(41))161+ reg.Register("window-1", "window-1", "shared-pane", oldPaneID, port.WebViewID(41))
132 162
133 _, _, ok := reg.Lookup(163 _, _, ok := reg.Lookup(
134 "window-1",164 "window-1",
135 "shared-pane",165 "shared-pane",
136 func(gotPaneID entity.PaneID) port.WebView {166 func(gotPaneID entity.PaneID) port.WebView {
137 assert.Equal(t, oldPaneID, gotPaneID)167 assert.Equal(t, oldPaneID, gotPaneID)
138- reg.Register("window-1", "shared-pane", newPaneID, newWebViewID)168+ reg.Register("window-1", "window-1", "shared-pane", newPaneID, newWebViewID)
139 return nil169 return nil
140 },170 },
141 func(gotPaneID entity.PaneID) (string, bool) {171 func(gotPaneID entity.PaneID) (string, bool) {
M internal/ui/coordinator/content/popup.go +40 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "time"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/logging"
@@ -105,6 +106,33 @@ type InsertPopupInput struct {
105106
106107 // NativePopupInput contains the data needed to host a native-required popup in
107108 // a dedicated top-level GTK window instead of the workspace.
109+// BrowserWindowInput contains an engine-created related WebView to adopt into
110+// a complete Dumber browser window. A successful callback transfers ownership.
111+type BrowserWindowInput struct {
112+ ParentPaneID entity.PaneID
113+ ParentWebViewID port.WebViewID
114+ PopupPane *entity.Pane
115+ PopupWebView port.WebView
116+ TargetURI string
117+ Request port.PopupRequest
118+ Ready bool
119+}
120+
121+type BrowserWindowResult struct {
122+ WindowID string
123+}
124+
125+// PopupStagingHost temporarily owns a popup widget while late engine metadata
126+// is unavailable. Detach transfers the widget without destroying it.
127+type PopupStagingHost interface {
128+ Detach() error
129+ Destroy()
130+}
131+
132+type StagePopupInput struct {
133+ PopupWebView port.WebView
134+}
135+
108136 type NativePopupInput struct {
109137 ParentPaneID entity.PaneID
110138 ParentWebViewID port.WebViewID
@@ -159,6 +187,18 @@ func (c *Coordinator) SetPopupWindowIDResolver(fn func(entity.PaneID) (string, b
159187 c.ensurePopupManager().setWindowIDResolver(fn)
160188 }
161189
190+func (c *Coordinator) SetPopupSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) {
191+ c.ensurePopupManager().setSourceHostResolver(fn)
192+}
193+
194+func (c *Coordinator) SetOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) {
195+ c.ensurePopupManager().setOnOpenBrowserWindow(fn)
196+}
197+
198+func (c *Coordinator) SetOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) {
199+ c.ensurePopupManager().setOnStagePopup(fn)
200+}
201+
162202 func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) {
163203 c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID)
164204 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "time"5 "time"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
@@ -105,6 +106,33 @@ type InsertPopupInput struct {
105 106
106 // NativePopupInput contains the data needed to host a native-required popup in107 // NativePopupInput contains the data needed to host a native-required popup in
107 // a dedicated top-level GTK window instead of the workspace.108 // a dedicated top-level GTK window instead of the workspace.
109+// BrowserWindowInput contains an engine-created related WebView to adopt into
110+// a complete Dumber browser window. A successful callback transfers ownership.
111+type BrowserWindowInput struct {
112+ ParentPaneID entity.PaneID
113+ ParentWebViewID port.WebViewID
114+ PopupPane *entity.Pane
115+ PopupWebView port.WebView
116+ TargetURI string
117+ Request port.PopupRequest
118+ Ready bool
119+}
120+
121+type BrowserWindowResult struct {
122+ WindowID string
123+}
124+
125+// PopupStagingHost temporarily owns a popup widget while late engine metadata
126+// is unavailable. Detach transfers the widget without destroying it.
127+type PopupStagingHost interface {
128+ Detach() error
129+ Destroy()
130+}
131+
132+type StagePopupInput struct {
133+ PopupWebView port.WebView
134+}
135+
108 type NativePopupInput struct {136 type NativePopupInput struct {
109 ParentPaneID entity.PaneID137 ParentPaneID entity.PaneID
110 ParentWebViewID port.WebViewID138 ParentWebViewID port.WebViewID
@@ -159,6 +187,18 @@ func (c *Coordinator) SetPopupWindowIDResolver(fn func(entity.PaneID) (string, b
159 c.ensurePopupManager().setWindowIDResolver(fn)187 c.ensurePopupManager().setWindowIDResolver(fn)
160 }188 }
161 189
190+func (c *Coordinator) SetPopupSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) {
191+ c.ensurePopupManager().setSourceHostResolver(fn)
192+}
193+
194+func (c *Coordinator) SetOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) {
195+ c.ensurePopupManager().setOnOpenBrowserWindow(fn)
196+}
197+
198+func (c *Coordinator) SetOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) {
199+ c.ensurePopupManager().setOnStagePopup(fn)
200+}
201+
162 func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) {202 func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) {
163 c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID)203 c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID)
164 }204 }
M internal/ui/coordinator/content/popup_manager.go +178 -48
@@ -17,20 +17,23 @@ import (
1717 // adapter layer so popup pane bookkeeping and workspace orchestration do not
1818 // leak into the application/usecase or domain layers.
1919 type popupManager struct {
20- factory port.WebViewFactory
21- popupConfig entity.BrowsingContextConfig
22- hasPopupConfig bool
23- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24- onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
25- onClosePane func(ctx context.Context, paneID entity.PaneID) error
26- generatePaneID func() string
27- windowIDForPane func(entity.PaneID) (string, bool)
28- policy browsingContextPolicy
29- namedContexts *namedBrowsingContextRegistry
30- pendingPopups map[port.WebViewID]*PendingPopup
31- popupOAuth map[port.WebViewID]*popupOAuthState
32- popupRefresh map[entity.PaneID]*time.Timer
33- mu sync.RWMutex
20+ factory port.WebViewFactory
21+ popupConfig entity.BrowsingContextConfig
22+ hasPopupConfig bool
23+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24+ onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error)
25+ onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
26+ onStagePopup func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error)
27+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
28+ generatePaneID func() string
29+ windowIDForPane func(entity.PaneID) (string, bool)
30+ sourceHostForPane func(entity.PaneID) dto.SourceHostKind
31+ policy browsingContextPolicy
32+ namedContexts *namedBrowsingContextRegistry
33+ pendingPopups map[port.WebViewID]*PendingPopup
34+ popupOAuth map[port.WebViewID]*popupOAuthState
35+ popupRefresh map[entity.PaneID]*time.Timer
36+ mu sync.RWMutex
3437 }
3538
3639 type popupOAuthState struct {
@@ -143,6 +146,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool
143146 pm.windowIDForPane = fn
144147 }
145148
149+func (pm *popupManager) setSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) {
150+ if pm != nil {
151+ pm.sourceHostForPane = fn
152+ }
153+}
154+
155+func (pm *popupManager) resolveSourceHost(paneID entity.PaneID) dto.SourceHostKind {
156+ if pm != nil && pm.sourceHostForPane != nil {
157+ if kind := pm.sourceHostForPane(paneID); kind != "" {
158+ return kind
159+ }
160+ }
161+ return dto.SourceHostWorkspace
162+}
163+
164+func (pm *popupManager) setOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) {
165+ if pm != nil {
166+ pm.onOpenBrowserWindow = fn
167+ }
168+}
169+
170+func (pm *popupManager) setOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) {
171+ if pm != nil {
172+ pm.onStagePopup = fn
173+ }
174+}
175+
176+func (pm *popupManager) browsingContextRoutingConfig(
177+ ctx context.Context,
178+ parentPaneID entity.PaneID,
179+) (*entity.BrowsingContextConfig, dto.SourceHostKind, bool) {
180+ cfg := pm.currentPopupConfig()
181+ sourceHost := pm.resolveSourceHost(parentPaneID)
182+ if sourceHost == dto.SourceHostWorkspace && cfg != nil && !cfg.OpenInNewPane {
183+ logging.FromContext(ctx).Debug().Msg("workspace browsing contexts disabled by config")
184+ return cfg, sourceHost, false
185+ }
186+ if pm.factory == nil {
187+ logging.FromContext(ctx).Warn().Msg("no webview factory, cannot create browsing context")
188+ return cfg, sourceHost, false
189+ }
190+ return cfg, sourceHost, true
191+}
192+
146193 func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
147194 behavior := GetBehavior(PopupTypeTab, cfg)
148195 placement := "right"
@@ -227,18 +274,32 @@ func (pm *popupManager) lookupReusableNamedPopup(
227274 }
228275
229276 func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
230- if pm == nil || pm.namedContexts == nil || wv == nil {
277+ if pm == nil || pm.windowIDForPane == nil {
231278 return
232279 }
233- name := reusableBrowsingContextName(frameName)
234- if name == "" || pm.windowIDForPane == nil {
280+ hostWindowID, ok := pm.windowIDForPane(paneID)
281+ if !ok {
235282 return
236283 }
237- windowID, ok := pm.windowIDForPane(parentPaneID)
238- if !ok || windowID == "" {
284+ pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID)
285+}
286+
287+func (pm *popupManager) storeReusableNamedPopupWithHost(
288+ parentPaneID entity.PaneID,
289+ frameName string,
290+ paneID entity.PaneID,
291+ wv port.WebView,
292+ hostWindowID string,
293+) {
294+ if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil {
295+ return
296+ }
297+ name := reusableBrowsingContextName(frameName)
298+ ownerWindowID, ok := pm.windowIDForPane(parentPaneID)
299+ if name == "" || !ok || ownerWindowID == "" || hostWindowID == "" {
239300 return
240301 }
241- pm.namedContexts.Register(windowID, name, paneID, wv.ID())
302+ pm.namedContexts.Register(ownerWindowID, hostWindowID, name, paneID, wv.ID())
242303 }
243304
244305 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
@@ -451,6 +512,59 @@ func (pm *popupManager) popupParentURIAtOpen(
451512 return parentURIAtOpen
452513 }
453514
515+func (pm *popupManager) openBrowserWindow(
516+ ctx context.Context,
517+ hooks popupCoordinatorHooks,
518+ parentPaneID entity.PaneID,
519+ parentWebViewID port.WebViewID,
520+ req port.PopupRequest,
521+ decision dto.HostDecision,
522+ ready bool,
523+) port.WebView {
524+ log := logging.FromContext(ctx)
525+ normalized := buildPopupBrowsingContextRequest(req)
526+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
527+ if pm.onOpenBrowserWindow == nil {
528+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
529+ return nil
530+ }
531+ popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, true)
532+ if err != nil {
533+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
534+ return nil
535+ }
536+ pm.setBrowsingContextDecision(popupWV, decision)
537+ paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
538+ if hooks.setupWebViewCallbacks != nil {
539+ hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
540+ }
541+ result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{
542+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane,
543+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready,
544+ })
545+ if err != nil || result.WindowID == "" {
546+ if err == nil {
547+ err = fmt.Errorf("browser window host returned an empty window ID")
548+ }
549+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
550+ popupWV.Destroy()
551+ return nil
552+ }
553+ if !req.NoJavaScriptAccess {
554+ pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
555+ }
556+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
557+ lifecycle.PrimePopupNavigation(req.TargetURI)
558+ }
559+ return popupWV
560+}
561+
562+func (pm *popupManager) readyOnCreate(engine dto.BrowserEngineKind) bool {
563+ // Deferred WebKit requests are staged and routed by a later phase. Every
564+ // immediate route must be visible without depending on another signal.
565+ return true
566+}
567+
454568 func (pm *popupManager) openNativePopup(
455569 ctx context.Context,
456570 parentPaneID entity.PaneID,
@@ -499,21 +613,8 @@ func (pm *popupManager) handlePopupCreate(
499613 ) port.WebView {
500614 log := logging.FromContext(ctx)
501615
502- log.Debug().
503- Str("parent_pane", string(parentPaneID)).
504- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
505- Str("frame_name", req.FrameName).
506- Bool("user_gesture", req.IsUserGesture).
507- Bool("no_javascript_access", req.NoJavaScriptAccess).
508- Msg("popup create request")
509-
510- cfg := pm.currentPopupConfig()
511- if cfg != nil && !cfg.OpenInNewPane {
512- log.Debug().Msg("popups disabled by config, blocking")
513- return nil
514- }
515- if pm.factory == nil {
516- log.Warn().Msg("no webview factory, cannot create popup")
616+ cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
617+ if !routingEnabled {
517618 return nil
518619 }
519620
@@ -521,16 +622,13 @@ func (pm *popupManager) handlePopupCreate(
521622 parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
522623
523624 request := buildPopupBrowsingContextRequest(req)
625+ request.SourceHost = sourceHost
524626 namedContextExists := false
525627 if !req.NoJavaScriptAccess {
526628 _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
527629 }
528630 decision := pm.policy.Decide(request, namedContextExists)
529- log.Debug().
530- Str("decision", string(decision.Kind)).
531- Str("reason", decision.Reason).
532- Str("context_name", decision.BrowsingContextName).
533- Msg("browsing context host decision")
631+ logBrowsingContextDecision(*log, request, decision)
534632
535633 switch decision.Kind {
536634 case dto.HostDecisionDeny:
@@ -549,8 +647,13 @@ func (pm *popupManager) handlePopupCreate(
549647 decision.Kind = dto.HostDecisionCreatePane
550648 decision.ReuseContextName = ""
551649 decision.Reason = "named browsing context unavailable; creating replacement pane"
650+ case dto.HostDecisionCreateBrowserWindow:
651+ return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
552652 case dto.HostDecisionCreateNativePopup:
553653 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
654+ case dto.HostDecisionAwaitPopupFeatures:
655+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil)
656+ return nil
554657 case dto.HostDecisionCreatePane:
555658 // Continue below.
556659 break
@@ -812,6 +915,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin
812915 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
813916 }
814917
918+func (pm *popupManager) openMiddleClickBrowserWindow(
919+ ctx context.Context,
920+ hooks popupCoordinatorHooks,
921+ parentPaneID entity.PaneID,
922+ parentWV port.WebView,
923+ uri string,
924+ decision dto.HostDecision,
925+) bool {
926+ req := port.PopupRequest{
927+ TargetURI: uri,
928+ FrameName: "_blank",
929+ TargetDisposition: dto.WindowDispositionNewTab,
930+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
931+ }
932+ newWV := pm.openBrowserWindow(ctx, hooks, parentPaneID, parentWV.ID(), req, decision, true)
933+ if newWV == nil {
934+ return false
935+ }
936+ if err := newWV.LoadURI(ctx, uri); err != nil {
937+ logging.FromContext(ctx).Error().Err(err).
938+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
939+ Msg("failed to load URI in detached window")
940+ }
941+ return true
942+}
943+
815944 func (pm *popupManager) handleLinkMiddleClick(
816945 ctx context.Context,
817946 hooks popupCoordinatorHooks,
@@ -825,13 +954,8 @@ func (pm *popupManager) handleLinkMiddleClick(
825954 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
826955 Msg("middle-click/ctrl+click on link")
827956
828- cfg := pm.currentPopupConfig()
829- if cfg != nil && !cfg.OpenInNewPane {
830- log.Debug().Msg("popups disabled by config, ignoring middle-click")
831- return false
832- }
833- if pm.factory == nil {
834- log.Warn().Msg("no webview factory, cannot handle middle-click")
957+ _, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
958+ if !routingEnabled {
835959 return false
836960 }
837961 if hooks.getWebView == nil {
@@ -844,11 +968,17 @@ func (pm *popupManager) handleLinkMiddleClick(
844968 return false
845969 }
846970
847- decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false)
971+ request := buildLinkBrowsingContextRequest(parentWV.ID(), uri)
972+ request.SourceHost = sourceHost
973+ decision := pm.policy.Decide(request, false)
974+ logBrowsingContextDecision(*log, request, decision)
848975 log.Debug().
849976 Str("decision", string(decision.Kind)).
850977 Str("reason", decision.Reason).
851978 Msg("middle-click browsing context host decision")
979+ if decision.Kind == dto.HostDecisionCreateBrowserWindow {
980+ return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
981+ }
852982 if decision.Kind != dto.HostDecisionCreatePane {
853983 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
854984 return false
@@ -17,20 +17,23 @@ import (
17 // adapter layer so popup pane bookkeeping and workspace orchestration do not17 // adapter layer so popup pane bookkeeping and workspace orchestration do not
18 // leak into the application/usecase or domain layers.18 // leak into the application/usecase or domain layers.
19 type popupManager struct {19 type popupManager struct {
20- factory port.WebViewFactory20+ factory port.WebViewFactory
21- popupConfig entity.BrowsingContextConfig21+ popupConfig entity.BrowsingContextConfig
22- hasPopupConfig bool22+ hasPopupConfig bool
23- onInsertPopup func(ctx context.Context, input InsertPopupInput) error23+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24- onOpenNativePopup func(ctx context.Context, input NativePopupInput) error24+ onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error)
25- onClosePane func(ctx context.Context, paneID entity.PaneID) error25+ onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
26- generatePaneID func() string26+ onStagePopup func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error)
27- windowIDForPane func(entity.PaneID) (string, bool)27+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
28- policy browsingContextPolicy28+ generatePaneID func() string
29- namedContexts *namedBrowsingContextRegistry29+ windowIDForPane func(entity.PaneID) (string, bool)
30- pendingPopups map[port.WebViewID]*PendingPopup30+ sourceHostForPane func(entity.PaneID) dto.SourceHostKind
31- popupOAuth map[port.WebViewID]*popupOAuthState31+ policy browsingContextPolicy
32- popupRefresh map[entity.PaneID]*time.Timer32+ namedContexts *namedBrowsingContextRegistry
33- mu sync.RWMutex33+ pendingPopups map[port.WebViewID]*PendingPopup
34+ popupOAuth map[port.WebViewID]*popupOAuthState
35+ popupRefresh map[entity.PaneID]*time.Timer
36+ mu sync.RWMutex
34 }37 }
35 38
36 type popupOAuthState struct {39 type popupOAuthState struct {
@@ -143,6 +146,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool
143 pm.windowIDForPane = fn146 pm.windowIDForPane = fn
144 }147 }
145 148
149+func (pm *popupManager) setSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) {
150+ if pm != nil {
151+ pm.sourceHostForPane = fn
152+ }
153+}
154+
155+func (pm *popupManager) resolveSourceHost(paneID entity.PaneID) dto.SourceHostKind {
156+ if pm != nil && pm.sourceHostForPane != nil {
157+ if kind := pm.sourceHostForPane(paneID); kind != "" {
158+ return kind
159+ }
160+ }
161+ return dto.SourceHostWorkspace
162+}
163+
164+func (pm *popupManager) setOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) {
165+ if pm != nil {
166+ pm.onOpenBrowserWindow = fn
167+ }
168+}
169+
170+func (pm *popupManager) setOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) {
171+ if pm != nil {
172+ pm.onStagePopup = fn
173+ }
174+}
175+
176+func (pm *popupManager) browsingContextRoutingConfig(
177+ ctx context.Context,
178+ parentPaneID entity.PaneID,
179+) (*entity.BrowsingContextConfig, dto.SourceHostKind, bool) {
180+ cfg := pm.currentPopupConfig()
181+ sourceHost := pm.resolveSourceHost(parentPaneID)
182+ if sourceHost == dto.SourceHostWorkspace && cfg != nil && !cfg.OpenInNewPane {
183+ logging.FromContext(ctx).Debug().Msg("workspace browsing contexts disabled by config")
184+ return cfg, sourceHost, false
185+ }
186+ if pm.factory == nil {
187+ logging.FromContext(ctx).Warn().Msg("no webview factory, cannot create browsing context")
188+ return cfg, sourceHost, false
189+ }
190+ return cfg, sourceHost, true
191+}
192+
146 func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {193 func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
147 behavior := GetBehavior(PopupTypeTab, cfg)194 behavior := GetBehavior(PopupTypeTab, cfg)
148 placement := "right"195 placement := "right"
@@ -227,18 +274,32 @@ func (pm *popupManager) lookupReusableNamedPopup(
227 }274 }
228 275
229 func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {276 func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
230- if pm == nil || pm.namedContexts == nil || wv == nil {277+ if pm == nil || pm.windowIDForPane == nil {
231 return278 return
232 }279 }
233- name := reusableBrowsingContextName(frameName)280+ hostWindowID, ok := pm.windowIDForPane(paneID)
234- if name == "" || pm.windowIDForPane == nil {281+ if !ok {
235 return282 return
236 }283 }
237- windowID, ok := pm.windowIDForPane(parentPaneID)284+ pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID)
238- if !ok || windowID == "" {285+}
286+
287+func (pm *popupManager) storeReusableNamedPopupWithHost(
288+ parentPaneID entity.PaneID,
289+ frameName string,
290+ paneID entity.PaneID,
291+ wv port.WebView,
292+ hostWindowID string,
293+) {
294+ if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil {
295+ return
296+ }
297+ name := reusableBrowsingContextName(frameName)
298+ ownerWindowID, ok := pm.windowIDForPane(parentPaneID)
299+ if name == "" || !ok || ownerWindowID == "" || hostWindowID == "" {
239 return300 return
240 }301 }
241- pm.namedContexts.Register(windowID, name, paneID, wv.ID())302+ pm.namedContexts.Register(ownerWindowID, hostWindowID, name, paneID, wv.ID())
242 }303 }
243 304
244 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {305 func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
@@ -451,6 +512,59 @@ func (pm *popupManager) popupParentURIAtOpen(
451 return parentURIAtOpen512 return parentURIAtOpen
452 }513 }
453 514
515+func (pm *popupManager) openBrowserWindow(
516+ ctx context.Context,
517+ hooks popupCoordinatorHooks,
518+ parentPaneID entity.PaneID,
519+ parentWebViewID port.WebViewID,
520+ req port.PopupRequest,
521+ decision dto.HostDecision,
522+ ready bool,
523+) port.WebView {
524+ log := logging.FromContext(ctx)
525+ normalized := buildPopupBrowsingContextRequest(req)
526+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
527+ if pm.onOpenBrowserWindow == nil {
528+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
529+ return nil
530+ }
531+ popupWV, err := pm.createPopupWebView(ctx, parentWebViewID, req.TargetURI, true)
532+ if err != nil {
533+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
534+ return nil
535+ }
536+ pm.setBrowsingContextDecision(popupWV, decision)
537+ paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
538+ if hooks.setupWebViewCallbacks != nil {
539+ hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
540+ }
541+ result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{
542+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane,
543+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready,
544+ })
545+ if err != nil || result.WindowID == "" {
546+ if err == nil {
547+ err = fmt.Errorf("browser window host returned an empty window ID")
548+ }
549+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
550+ popupWV.Destroy()
551+ return nil
552+ }
553+ if !req.NoJavaScriptAccess {
554+ pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
555+ }
556+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
557+ lifecycle.PrimePopupNavigation(req.TargetURI)
558+ }
559+ return popupWV
560+}
561+
562+func (pm *popupManager) readyOnCreate(engine dto.BrowserEngineKind) bool {
563+ // Deferred WebKit requests are staged and routed by a later phase. Every
564+ // immediate route must be visible without depending on another signal.
565+ return true
566+}
567+
454 func (pm *popupManager) openNativePopup(568 func (pm *popupManager) openNativePopup(
455 ctx context.Context,569 ctx context.Context,
456 parentPaneID entity.PaneID,570 parentPaneID entity.PaneID,
@@ -499,21 +613,8 @@ func (pm *popupManager) handlePopupCreate(
499 ) port.WebView {613 ) port.WebView {
500 log := logging.FromContext(ctx)614 log := logging.FromContext(ctx)
501 615
502- log.Debug().616+ cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
503- Str("parent_pane", string(parentPaneID)).617+ if !routingEnabled {
504- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
505- Str("frame_name", req.FrameName).
506- Bool("user_gesture", req.IsUserGesture).
507- Bool("no_javascript_access", req.NoJavaScriptAccess).
508- Msg("popup create request")
509-
510- cfg := pm.currentPopupConfig()
511- if cfg != nil && !cfg.OpenInNewPane {
512- log.Debug().Msg("popups disabled by config, blocking")
513- return nil
514- }
515- if pm.factory == nil {
516- log.Warn().Msg("no webview factory, cannot create popup")
517 return nil618 return nil
518 }619 }
519 620
@@ -521,16 +622,13 @@ func (pm *popupManager) handlePopupCreate(
521 parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)622 parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
522 623
523 request := buildPopupBrowsingContextRequest(req)624 request := buildPopupBrowsingContextRequest(req)
625+ request.SourceHost = sourceHost
524 namedContextExists := false626 namedContextExists := false
525 if !req.NoJavaScriptAccess {627 if !req.NoJavaScriptAccess {
526 _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)628 _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
527 }629 }
528 decision := pm.policy.Decide(request, namedContextExists)630 decision := pm.policy.Decide(request, namedContextExists)
529- log.Debug().631+ logBrowsingContextDecision(*log, request, decision)
530- Str("decision", string(decision.Kind)).
531- Str("reason", decision.Reason).
532- Str("context_name", decision.BrowsingContextName).
533- Msg("browsing context host decision")
534 632
535 switch decision.Kind {633 switch decision.Kind {
536 case dto.HostDecisionDeny:634 case dto.HostDecisionDeny:
@@ -549,8 +647,13 @@ func (pm *popupManager) handlePopupCreate(
549 decision.Kind = dto.HostDecisionCreatePane647 decision.Kind = dto.HostDecisionCreatePane
550 decision.ReuseContextName = ""648 decision.ReuseContextName = ""
551 decision.Reason = "named browsing context unavailable; creating replacement pane"649 decision.Reason = "named browsing context unavailable; creating replacement pane"
650+ case dto.HostDecisionCreateBrowserWindow:
651+ return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
552 case dto.HostDecisionCreateNativePopup:652 case dto.HostDecisionCreateNativePopup:
553 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)653 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
654+ case dto.HostDecisionAwaitPopupFeatures:
655+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil)
656+ return nil
554 case dto.HostDecisionCreatePane:657 case dto.HostDecisionCreatePane:
555 // Continue below.658 // Continue below.
556 break659 break
@@ -812,6 +915,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin
812 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")915 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
813 }916 }
814 917
918+func (pm *popupManager) openMiddleClickBrowserWindow(
919+ ctx context.Context,
920+ hooks popupCoordinatorHooks,
921+ parentPaneID entity.PaneID,
922+ parentWV port.WebView,
923+ uri string,
924+ decision dto.HostDecision,
925+) bool {
926+ req := port.PopupRequest{
927+ TargetURI: uri,
928+ FrameName: "_blank",
929+ TargetDisposition: dto.WindowDispositionNewTab,
930+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
931+ }
932+ newWV := pm.openBrowserWindow(ctx, hooks, parentPaneID, parentWV.ID(), req, decision, true)
933+ if newWV == nil {
934+ return false
935+ }
936+ if err := newWV.LoadURI(ctx, uri); err != nil {
937+ logging.FromContext(ctx).Error().Err(err).
938+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
939+ Msg("failed to load URI in detached window")
940+ }
941+ return true
942+}
943+
815 func (pm *popupManager) handleLinkMiddleClick(944 func (pm *popupManager) handleLinkMiddleClick(
816 ctx context.Context,945 ctx context.Context,
817 hooks popupCoordinatorHooks,946 hooks popupCoordinatorHooks,
@@ -825,13 +954,8 @@ func (pm *popupManager) handleLinkMiddleClick(
825 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).954 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
826 Msg("middle-click/ctrl+click on link")955 Msg("middle-click/ctrl+click on link")
827 956
828- cfg := pm.currentPopupConfig()957+ _, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
829- if cfg != nil && !cfg.OpenInNewPane {958+ if !routingEnabled {
830- log.Debug().Msg("popups disabled by config, ignoring middle-click")
831- return false
832- }
833- if pm.factory == nil {
834- log.Warn().Msg("no webview factory, cannot handle middle-click")
835 return false959 return false
836 }960 }
837 if hooks.getWebView == nil {961 if hooks.getWebView == nil {
@@ -844,11 +968,17 @@ func (pm *popupManager) handleLinkMiddleClick(
844 return false968 return false
845 }969 }
846 970
847- decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false)971+ request := buildLinkBrowsingContextRequest(parentWV.ID(), uri)
972+ request.SourceHost = sourceHost
973+ decision := pm.policy.Decide(request, false)
974+ logBrowsingContextDecision(*log, request, decision)
848 log.Debug().975 log.Debug().
849 Str("decision", string(decision.Kind)).976 Str("decision", string(decision.Kind)).
850 Str("reason", decision.Reason).977 Str("reason", decision.Reason).
851 Msg("middle-click browsing context host decision")978 Msg("middle-click browsing context host decision")
979+ if decision.Kind == dto.HostDecisionCreateBrowserWindow {
980+ return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
981+ }
852 if decision.Kind != dto.HostDecisionCreatePane {982 if decision.Kind != dto.HostDecisionCreatePane {
853 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")983 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
854 return false984 return false
M internal/ui/coordinator/content/popup_test.go +80 -1
@@ -151,6 +151,85 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
151151 return ctx, parentPaneID, parentWV, popupWV, c
152152 }
153153
154+func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
155+ ctx := context.Background()
156+ parentPaneID := entity.PaneID("floating")
157+ parentWV := mocks.NewMockWebView(t)
158+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159+ popupWV := mocks.NewMockWebView(t)
160+ popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
161+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
162+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163+
164+ factory := mocks.NewMockWebViewFactory(t)
165+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
166+ c := &Coordinator{popups: newPopupManager()}
167+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
168+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
169+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
170+ t.Fatal("floating request must not use workspace insertion")
171+ return nil
172+ })
173+ called := false
174+ c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
175+ called = true
176+ assert.Equal(t, parentPaneID, input.ParentPaneID)
177+ assert.Same(t, popupWV, input.PopupWebView)
178+ assert.Equal(t, "https://example.com/new", input.TargetURI)
179+ return BrowserWindowResult{WindowID: "detached-window"}, nil
180+ })
181+
182+ got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
183+ Engine: dto.BrowserEngineCEF,
184+ TargetURI: "https://example.com/new",
185+ FrameName: "_blank",
186+ TargetDisposition: dto.WindowDispositionNewTab,
187+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
188+ })
189+
190+ assert.Same(t, popupWV, got)
191+ assert.True(t, called)
192+}
193+
194+func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
195+ ctx := context.Background()
196+ parentPaneID := entity.PaneID("floating")
197+ parentWV := mocks.NewMockWebView(t)
198+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe()
199+ popupWV := mocks.NewMockWebView(t)
200+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
201+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
202+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
203+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
204+ factory := mocks.NewMockWebViewFactory(t)
205+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
206+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
207+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
208+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
209+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
210+ t.Fatal("floating middle-click must not use workspace insertion")
211+ return nil
212+ })
213+ c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
214+ assert.Same(t, popupWV, input.PopupWebView)
215+ return BrowserWindowResult{WindowID: "detached-window"}, nil
216+ })
217+
218+ assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
219+}
220+
221+func TestPopupWorkspaceBlankRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
222+ parentWV := mocks.NewMockWebView(t)
223+ factory := mocks.NewMockWebViewFactory(t)
224+ c := &Coordinator{popups: newPopupManager()}
225+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
226+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
227+
228+ got := c.handlePopupCreate(context.Background(), "workspace", parentWV, port.PopupRequest{TargetURI: "https://example.com/new"})
229+
230+ assert.Nil(t, got)
231+}
232+
154233 func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
155234 ctx := context.Background()
156235 parentPaneID := entity.PaneID("parent-pane")
@@ -591,7 +670,7 @@ func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t
591670 }
592671 c.SetPopupConfig(factory, nil, nil)
593672 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
594- c.popups.namedContexts.Register("window-1", "shared-pane", stalePaneID, port.WebViewID(202))
673+ c.popups.namedContexts.Register("window-1", "window-1", "shared-pane", stalePaneID, port.WebViewID(202))
595674 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
596675 insertCalls++
597676 return nil
@@ -151,6 +151,85 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
151 return ctx, parentPaneID, parentWV, popupWV, c151 return ctx, parentPaneID, parentWV, popupWV, c
152 }152 }
153 153
154+func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
155+ ctx := context.Background()
156+ parentPaneID := entity.PaneID("floating")
157+ parentWV := mocks.NewMockWebView(t)
158+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159+ popupWV := mocks.NewMockWebView(t)
160+ popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
161+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
162+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163+
164+ factory := mocks.NewMockWebViewFactory(t)
165+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
166+ c := &Coordinator{popups: newPopupManager()}
167+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
168+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
169+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
170+ t.Fatal("floating request must not use workspace insertion")
171+ return nil
172+ })
173+ called := false
174+ c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
175+ called = true
176+ assert.Equal(t, parentPaneID, input.ParentPaneID)
177+ assert.Same(t, popupWV, input.PopupWebView)
178+ assert.Equal(t, "https://example.com/new", input.TargetURI)
179+ return BrowserWindowResult{WindowID: "detached-window"}, nil
180+ })
181+
182+ got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
183+ Engine: dto.BrowserEngineCEF,
184+ TargetURI: "https://example.com/new",
185+ FrameName: "_blank",
186+ TargetDisposition: dto.WindowDispositionNewTab,
187+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
188+ })
189+
190+ assert.Same(t, popupWV, got)
191+ assert.True(t, called)
192+}
193+
194+func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
195+ ctx := context.Background()
196+ parentPaneID := entity.PaneID("floating")
197+ parentWV := mocks.NewMockWebView(t)
198+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe()
199+ popupWV := mocks.NewMockWebView(t)
200+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
201+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
202+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
203+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
204+ factory := mocks.NewMockWebViewFactory(t)
205+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
206+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
207+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
208+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
209+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
210+ t.Fatal("floating middle-click must not use workspace insertion")
211+ return nil
212+ })
213+ c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
214+ assert.Same(t, popupWV, input.PopupWebView)
215+ return BrowserWindowResult{WindowID: "detached-window"}, nil
216+ })
217+
218+ assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
219+}
220+
221+func TestPopupWorkspaceBlankRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
222+ parentWV := mocks.NewMockWebView(t)
223+ factory := mocks.NewMockWebViewFactory(t)
224+ c := &Coordinator{popups: newPopupManager()}
225+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
226+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
227+
228+ got := c.handlePopupCreate(context.Background(), "workspace", parentWV, port.PopupRequest{TargetURI: "https://example.com/new"})
229+
230+ assert.Nil(t, got)
231+}
232+
154 func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {233 func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
155 ctx := context.Background()234 ctx := context.Background()
156 parentPaneID := entity.PaneID("parent-pane")235 parentPaneID := entity.PaneID("parent-pane")
@@ -591,7 +670,7 @@ func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t
591 }670 }
592 c.SetPopupConfig(factory, nil, nil)671 c.SetPopupConfig(factory, nil, nil)
593 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })672 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
594- c.popups.namedContexts.Register("window-1", "shared-pane", stalePaneID, port.WebViewID(202))673+ c.popups.namedContexts.Register("window-1", "window-1", "shared-pane", stalePaneID, port.WebViewID(202))
595 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {674 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
596 insertCalls++675 insertCalls++
597 return nil676 return nil
M internal/ui/coordinator/tab.go +15 -5
@@ -35,8 +35,8 @@ type TabCoordinator struct {
3535 onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
3636 onQuit func()
3737 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
38- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
39- onStateChanged func() // For session snapshots
38+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs
39+ onStateChanged func() // For session snapshots
4040 }
4141
4242 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
132132 }
133133
134134 // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
135-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
135+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error) {
136136 c.onAttachPopupToTab = fn
137137 }
138138
@@ -540,9 +540,19 @@ func (c *TabCoordinator) CreateWithPane(
540540 // Update tab bar visibility
541541 c.UpdateBarVisibility(ctx, target)
542542
543- // Attach the popup WebView to the new tab's workspace
543+ // Attach the popup WebView to the new tab's workspace. The caller retains
544+ // WebView ownership until this succeeds.
544545 if c.onAttachPopupToTab != nil {
545- c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)
546+ if err := c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv); err != nil {
547+ target.Tabs.Remove(output.Tab.ID)
548+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
549+ target.MainWindow.TabBar().RemoveTab(output.Tab.ID)
550+ }
551+ if c.onTabClosed != nil {
552+ c.onTabClosed(ctx, target, output.Tab)
553+ }
554+ return nil, fmt.Errorf("attach popup webview: %w", err)
555+ }
546556 }
547557
548558 // Switch to the new tab's workspace view
@@ -35,8 +35,8 @@ type TabCoordinator struct {
35 onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)35 onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
36 onQuit func()36 onQuit func()
37 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)37 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
38- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs38+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs
39- onStateChanged func() // For session snapshots39+ onStateChanged func() // For session snapshots
40 }40 }
41 41
42 // TabCoordinatorConfig holds configuration for TabCoordinator.42 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
132 }132 }
133 133
134 // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.134 // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
135-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {135+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error) {
136 c.onAttachPopupToTab = fn136 c.onAttachPopupToTab = fn
137 }137 }
138 138
@@ -540,9 +540,19 @@ func (c *TabCoordinator) CreateWithPane(
540 // Update tab bar visibility540 // Update tab bar visibility
541 c.UpdateBarVisibility(ctx, target)541 c.UpdateBarVisibility(ctx, target)
542 542
543- // Attach the popup WebView to the new tab's workspace543+ // Attach the popup WebView to the new tab's workspace. The caller retains
544+ // WebView ownership until this succeeds.
544 if c.onAttachPopupToTab != nil {545 if c.onAttachPopupToTab != nil {
545- c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)546+ if err := c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv); err != nil {
547+ target.Tabs.Remove(output.Tab.ID)
548+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
549+ target.MainWindow.TabBar().RemoveTab(output.Tab.ID)
550+ }
551+ if c.onTabClosed != nil {
552+ c.onTabClosed(ctx, target, output.Tab)
553+ }
554+ return nil, fmt.Errorf("attach popup webview: %w", err)
555+ }
546 }556 }
547 557
548 // Switch to the new tab's workspace view558 // Switch to the new tab's workspace view

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