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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +629 -130
M internal/ui/app.go +37 -31
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | "unsafe" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 16 | 17 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | 18 | "github.com/bnema/dumber/internal/application/usecase" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -3077,9 +3078,7 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 3077 | 3078 | } | |
| 3078 | 3079 | }) | |
| 3079 | 3080 | // 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) | |
| 3083 | 3082 | ||
| 3084 | 3083 | for _, bw := range a.browserWindows { | |
| 3085 | 3084 | a.wireBrowserWindowTabBar(ctx, bw) | |
| @@ -3157,13 +3156,14 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3157 | 3156 | &runtimeCfg.Workspace.BrowsingContexts, | |
| 3158 | 3157 | a.generateID, | |
| 3159 | 3158 | ) | |
| 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 | |
| 3164 | 3162 | } | |
| 3165 | - | return bw.id, true | |
| 3163 | + | return dto.SourceHostWorkspace | |
| 3166 | 3164 | }) | |
| 3165 | + | a.contentCoord.SetPopupWindowIDResolver(a.popupOwnerWindowIDForPane) | |
| 3166 | + | a.contentCoord.SetOnOpenBrowserWindow(a.openPopupBrowserWindow) | |
| 3167 | 3167 | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | |
| 3168 | 3168 | if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil { | |
| 3169 | 3169 | a.activateBrowserWindow(bw) | |
| @@ -3930,55 +3930,43 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||
| 3930 | 3930 | ||
| 3931 | 3931 | // attachPopupToTab attaches a popup WebView to a newly created tab. | |
| 3932 | 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 | 3934 | log := logging.FromContext(ctx) | |
| 3935 | - | cleanupUnattachedPopup := func() { | |
| 3936 | - | if wv != nil { | |
| 3937 | - | wv.Destroy() | |
| 3938 | - | } | |
| 3939 | - | } | |
| 3940 | - | ||
| 3941 | 3935 | wsView := a.workspaceViews[tabID] | |
| 3942 | 3936 | if wsView == nil { | |
| 3943 | - | cleanupUnattachedPopup() | |
| 3944 | 3937 | 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) | |
| 3946 | 3939 | } | |
| 3947 | 3940 | if pane == nil { | |
| 3948 | - | cleanupUnattachedPopup() | |
| 3949 | 3941 | log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil") | |
| 3950 | - | return | |
| 3942 | + | return fmt.Errorf("popup pane is nil") | |
| 3951 | 3943 | } | |
| 3952 | 3944 | if wsView.GetPaneView(pane.ID) == nil { | |
| 3953 | - | cleanupUnattachedPopup() | |
| 3954 | 3945 | 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) | |
| 3956 | 3947 | } | |
| 3957 | 3948 | if a.contentCoord == nil { | |
| 3958 | - | cleanupUnattachedPopup() | |
| 3959 | 3949 | 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") | |
| 3961 | 3951 | } | |
| 3962 | 3952 | ||
| 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. | |
| 3967 | 3955 | widget := a.contentCoord.WrapWidget(ctx, wv) | |
| 3968 | 3956 | if widget == nil { | |
| 3969 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |
| 3970 | 3957 | log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview") | |
| 3971 | - | return | |
| 3958 | + | return fmt.Errorf("wrap popup webview") | |
| 3972 | 3959 | } | |
| 3973 | 3960 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | |
| 3974 | - | a.contentCoord.ReleaseWebView(ctx, pane.ID) | |
| 3975 | 3961 | 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) | |
| 3977 | 3963 | } | |
| 3964 | + | a.contentCoord.RegisterPopupWebView(pane.ID, wv) | |
| 3978 | 3965 | log.Debug(). | |
| 3979 | 3966 | Str("tab_id", string(tabID)). | |
| 3980 | 3967 | Str("pane_id", string(pane.ID)). | |
| 3981 | 3968 | Msg("popup webview attached to tab") | |
| 3969 | + | return nil | |
| 3982 | 3970 | } | |
| 3983 | 3971 | ||
| 3984 | 3972 | // switchWorkspaceView swaps the displayed workspace view for a tab. | |
| @@ -4584,6 +4572,24 @@ func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSe | |||
| 4584 | 4572 | 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 | 4593 | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |
| 4588 | 4594 | if session == nil || session.overlay == nil || session.resizeWatcherActive { | |
| 4589 | 4595 | 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 attachment | 3080 | // 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, true | 3163 | + | 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 | - | return | 3938 | + | 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 | - | return | 3942 | + | 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 | - | return | 3946 | + | 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 | - | return | 3950 | + | 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 | - | return | 3958 | + | 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 | - | return | 3962 | + | 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 nil | 4572 | 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 | return | 4595 | return | ||
M internal/ui/app_browser_launch_test.go +15 -14
| @@ -2279,6 +2279,7 @@ func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) { | |||
| 2279 | 2279 | mainWindow: first.mainWindow, | |
| 2280 | 2280 | } | |
| 2281 | 2281 | app.initTabCoordinator(ctx) | |
| 2282 | + | app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil }) | |
| 2282 | 2283 | ||
| 2283 | 2284 | err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{ | |
| 2284 | 2285 | ParentPaneID: secondTab.Workspace.ActivePaneID, | |
| @@ -2538,28 +2539,28 @@ func (f *recordingWebView) IsPlayingAudio() bool { return f | |||
| 2538 | 2539 | func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 } | |
| 2539 | 2540 | func (f *recordingWebView) Destroy() { f.destroyCalls++ } | |
| 2540 | 2541 | ||
| 2541 | - | func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) { | |
| 2542 | + | func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) { | |
| 2542 | 2543 | ctx := context.Background() | |
| 2543 | 2544 | popupWV := &recordingWebView{id: 1} | |
| 2544 | 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 | - | 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) | |
| 2550 | 2551 | } | |
| 2551 | 2552 | } | |
| 2552 | 2553 | ||
| 2553 | - | func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) { | |
| 2554 | + | func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) { | |
| 2554 | 2555 | ctx := context.Background() | |
| 2555 | 2556 | popupWV := &recordingWebView{id: 1} | |
| 2556 | 2557 | tabID := entity.TabID("tab-1") | |
| 2557 | 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 | - | 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) | |
| 2563 | 2564 | } | |
| 2564 | 2565 | } | |
| 2565 | 2566 | ||
| @@ -2576,13 +2577,13 @@ func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) | |||
| 2576 | 2577 | } | |
| 2577 | 2578 | ||
| 2578 | 2579 | popupWV := &recordingWebView{id: 1} | |
| 2579 | - | app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2580 | + | err := app.attachPopupToTab(ctx, tabID, pane, popupWV) | |
| 2580 | 2581 | ||
| 2581 | 2582 | if got := contentCoord.GetWebView(pane.ID); got != nil { | |
| 2582 | 2583 | t.Fatalf("popup webview was registered for missing pane view: %v", got) | |
| 2583 | 2584 | } | |
| 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) | |
| 2586 | 2587 | } | |
| 2587 | 2588 | } | |
| 2588 | 2589 | ||
| @@ -2617,13 +2618,13 @@ func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) { | |||
| 2617 | 2618 | } | |
| 2618 | 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 | 2623 | if got := contentCoord.GetWebView(pane.ID); got != nil { | |
| 2623 | 2624 | t.Fatalf("popup webview registration remained after wrap failure: %v", got) | |
| 2624 | 2625 | } | |
| 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) | |
| 2627 | 2628 | } | |
| 2628 | 2629 | } | |
| 2629 | 2630 | ||
| @@ -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 | |||
| 23 | 23 | case strings.TrimSpace(req.TargetURI) == "": | |
| 24 | 24 | return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI") | |
| 25 | 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 | 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 | 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 | 42 | name := reusableBrowsingContextName(req.TargetFrameName) | |
| 34 | 43 | if namedContextExists && name != "" && !req.NoJavaScriptAccess { | |
| 35 | 44 | decision.ReuseContextName = name | |
| 36 | 45 | 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 | 51 | decision.BrowsingContextName = name | |
| 40 | 52 | ||
| 41 | 53 | if req.SourceHost == dto.SourceHostFloating { | |
| 42 | 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 | 62 | func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision { | |
| 48 | 63 | switch { | |
| 49 | 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 | 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 | 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 | 88 | decision.Kind = kind | |
| 60 | 89 | decision.ReasonCode = code | |
| 61 | 90 | decision.Reason = reason | |
| @@ -72,7 +101,13 @@ func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContex | |||
| 72 | 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 | 111 | logger.Error().Err(err). | |
| 77 | 112 | Str("engine", string(req.Engine)). | |
| 78 | 113 | 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 = name | 44 | decision.ReuseContextName = name | ||
| 36 | decision.BrowsingContextName = name | 45 | 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 = name | 51 | 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 = kind | 88 | decision.Kind = kind | ||
| 60 | decision.ReasonCode = code | 89 | decision.ReasonCode = code | ||
| 61 | decision.Reason = reason | 90 | 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 | |||
| 223 | 223 | return "", false | |
| 224 | 224 | }) | |
| 225 | 225 | 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 | 228 | _, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane) | |
| 229 | 229 | require.True(t, okBefore) | |
| @@ -223,7 +223,7 @@ func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *test | |||||
| 223 | return "", false | 223 | return "", false | ||
| 224 | }) | 224 | }) | ||
| 225 | c.webViews[paneID] = wv | 225 | 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 ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | type namedBrowsingContextKey struct { | |
| 11 | - | WindowID string | |
| 12 | - | Name string | |
| 11 | + | OwnerWindowID string | |
| 12 | + | Name string | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 15 | type namedBrowsingContextState struct { | |
| 16 | - | PaneID entity.PaneID | |
| 17 | - | WebViewID port.WebViewID | |
| 16 | + | PaneID entity.PaneID | |
| 17 | + | WebViewID port.WebViewID | |
| 18 | + | HostWindowID string | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 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 | - | 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 { | |
| 33 | 34 | return | |
| 34 | 35 | } | |
| 35 | 36 | 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, | |
| 39 | 39 | } | |
| 40 | 40 | r.mu.Unlock() | |
| 41 | 41 | } | |
| @@ -49,7 +49,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||
| 49 | 49 | return namedBrowsingContextState{}, nil, false | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | key := namedBrowsingContextKey{WindowID: windowID, Name: name} | |
| 52 | + | key := namedBrowsingContextKey{OwnerWindowID: windowID, Name: name} | |
| 53 | 53 | r.mu.RLock() | |
| 54 | 54 | state, ok := r.contexts[key] | |
| 55 | 55 | r.mu.RUnlock() | |
| @@ -58,7 +58,7 @@ func (r *namedBrowsingContextRegistry) Lookup( | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | currentWindowID, ok := resolveWindowID(state.PaneID) | |
| 61 | - | if !ok || currentWindowID != windowID { | |
| 61 | + | if !ok || currentWindowID != state.HostWindowID { | |
| 62 | 62 | r.deleteIfStateMatches(key, state) | |
| 63 | 63 | return namedBrowsingContextState{}, nil, false | |
| 64 | 64 | } | |
| @@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) { | |||
| 114 | 114 | return | |
| 115 | 115 | } | |
| 116 | 116 | 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 { | |
| 119 | 119 | delete(r.contexts, key) | |
| 120 | 120 | } | |
| 121 | 121 | } | |
| @@ -8,13 +8,14 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | type namedBrowsingContextKey struct { | 10 | type namedBrowsingContextKey struct { | ||
| 11 | - | WindowID string | 11 | + | OwnerWindowID string |
| 12 | - | Name string | 12 | + | Name string |
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | type namedBrowsingContextState struct { | 15 | type namedBrowsingContextState struct { | ||
| 16 | - | PaneID entity.PaneID | 16 | + | PaneID entity.PaneID |
| 17 | - | WebViewID port.WebViewID | 17 | + | 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 | return | 34 | 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, false | 49 | 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, false | 63 | return namedBrowsingContextState{}, nil, false | ||
| 64 | } | 64 | } | ||
| @@ -114,8 +114,8 @@ func (r *namedBrowsingContextRegistry) UnregisterWindow(windowID string) { | |||||
| 114 | return | 114 | 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) | |||
| 15 | 15 | reg := newNamedBrowsingContextRegistry() | |
| 16 | 16 | paneID := entity.PaneID("pane-1") | |
| 17 | 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 | 20 | wv := mocks.NewMockWebView(t) | |
| 21 | 21 | wv.EXPECT().IsDestroyed().Return(false).Once() | |
| @@ -39,11 +39,41 @@ func TestNamedBrowsingContextRegistry_LookupReusesWithinSameWindow(t *testing.T) | |||
| 39 | 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 | 72 | func TestNamedBrowsingContextRegistry_LookupIsolatedAcrossWindows(t *testing.T) { | |
| 43 | 73 | t.Parallel() | |
| 44 | 74 | ||
| 45 | 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 | 78 | _, _, ok := reg.Lookup( | |
| 49 | 79 | "window-2", | |
| @@ -60,7 +90,7 @@ func TestNamedBrowsingContextRegistry_DropsStaleWindowOwnershipOnLookup(t *testi | |||
| 60 | 90 | ||
| 61 | 91 | reg := newNamedBrowsingContextRegistry() | |
| 62 | 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 | 95 | _, _, ok := reg.Lookup( | |
| 66 | 96 | "window-1", | |
| @@ -86,7 +116,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||
| 86 | 116 | oldPaneID := entity.PaneID("pane-old") | |
| 87 | 117 | newPaneID := entity.PaneID("pane-new") | |
| 88 | 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 | 121 | _, _, ok := reg.Lookup( | |
| 92 | 122 | "window-1", | |
| @@ -94,7 +124,7 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterW | |||
| 94 | 124 | func(entity.PaneID) port.WebView { return nil }, | |
| 95 | 125 | func(gotPaneID entity.PaneID) (string, bool) { | |
| 96 | 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 | 128 | return "window-2", true | |
| 99 | 129 | }, | |
| 100 | 130 | ) | |
| @@ -128,14 +158,14 @@ func TestNamedBrowsingContextRegistry_LookupDoesNotDeleteReRegisteredEntryAfterS | |||
| 128 | 158 | oldPaneID := entity.PaneID("pane-old") | |
| 129 | 159 | newPaneID := entity.PaneID("pane-new") | |
| 130 | 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 | 163 | _, _, ok := reg.Lookup( | |
| 134 | 164 | "window-1", | |
| 135 | 165 | "shared-pane", | |
| 136 | 166 | func(gotPaneID entity.PaneID) port.WebView { | |
| 137 | 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 | 169 | return nil | |
| 140 | 170 | }, | |
| 141 | 171 | 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", true | 128 | 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 nil | 169 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -105,6 +106,33 @@ type InsertPopupInput struct { | |||
| 105 | 106 | ||
| 106 | 107 | // NativePopupInput contains the data needed to host a native-required popup in | |
| 107 | 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 | 136 | type NativePopupInput struct { | |
| 109 | 137 | ParentPaneID entity.PaneID | |
| 110 | 138 | ParentWebViewID port.WebViewID | |
| @@ -159,6 +187,18 @@ func (c *Coordinator) SetPopupWindowIDResolver(fn func(entity.PaneID) (string, b | |||
| 159 | 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 | 202 | func (c *Coordinator) ClearPopupNamedContextsForWindow(windowID string) { | |
| 163 | 203 | c.ensurePopupManager().clearReusableNamedPopupsForWindow(windowID) | |
| 164 | 204 | } | |
| @@ -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 in | 107 | // 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.PaneID | 137 | ParentPaneID entity.PaneID | ||
| 110 | ParentWebViewID port.WebViewID | 138 | 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 ( | |||
| 17 | 17 | // adapter layer so popup pane bookkeeping and workspace orchestration do not | |
| 18 | 18 | // leak into the application/usecase or domain layers. | |
| 19 | 19 | 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 | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | type popupOAuthState struct { | |
| @@ -143,6 +146,50 @@ func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool | |||
| 143 | 146 | 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 | 193 | func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) { | |
| 147 | 194 | behavior := GetBehavior(PopupTypeTab, cfg) | |
| 148 | 195 | placement := "right" | |
| @@ -227,18 +274,32 @@ func (pm *popupManager) lookupReusableNamedPopup( | |||
| 227 | 274 | } | |
| 228 | 275 | ||
| 229 | 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 | 278 | return | |
| 232 | 279 | } | |
| 233 | - | name := reusableBrowsingContextName(frameName) | |
| 234 | - | if name == "" || pm.windowIDForPane == nil { | |
| 280 | + | hostWindowID, ok := pm.windowIDForPane(paneID) | |
| 281 | + | if !ok { | |
| 235 | 282 | return | |
| 236 | 283 | } | |
| 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 == "" { | |
| 239 | 300 | 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 | 305 | func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) { | |
| @@ -451,6 +512,59 @@ func (pm *popupManager) popupParentURIAtOpen( | |||
| 451 | 512 | 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 | 568 | func (pm *popupManager) openNativePopup( | |
| 455 | 569 | ctx context.Context, | |
| 456 | 570 | parentPaneID entity.PaneID, | |
| @@ -499,21 +613,8 @@ func (pm *popupManager) handlePopupCreate( | |||
| 499 | 613 | ) port.WebView { | |
| 500 | 614 | log := logging.FromContext(ctx) | |
| 501 | 615 | ||
| 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 { | |
| 517 | 618 | return nil | |
| 518 | 619 | } | |
| 519 | 620 | ||
| @@ -521,16 +622,13 @@ func (pm *popupManager) handlePopupCreate( | |||
| 521 | 622 | parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI) | |
| 522 | 623 | ||
| 523 | 624 | request := buildPopupBrowsingContextRequest(req) | |
| 625 | + | request.SourceHost = sourceHost | |
| 524 | 626 | namedContextExists := false | |
| 525 | 627 | if !req.NoJavaScriptAccess { | |
| 526 | 628 | _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks) | |
| 527 | 629 | } | |
| 528 | 630 | 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) | |
| 534 | 632 | ||
| 535 | 633 | switch decision.Kind { | |
| 536 | 634 | case dto.HostDecisionDeny: | |
| @@ -549,8 +647,13 @@ func (pm *popupManager) handlePopupCreate( | |||
| 549 | 647 | decision.Kind = dto.HostDecisionCreatePane | |
| 550 | 648 | decision.ReuseContextName = "" | |
| 551 | 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 | 652 | case dto.HostDecisionCreateNativePopup: | |
| 553 | 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 | 657 | case dto.HostDecisionCreatePane: | |
| 555 | 658 | // Continue below. | |
| 556 | 659 | break | |
| @@ -812,6 +915,32 @@ func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordin | |||
| 812 | 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 | 944 | func (pm *popupManager) handleLinkMiddleClick( | |
| 816 | 945 | ctx context.Context, | |
| 817 | 946 | hooks popupCoordinatorHooks, | |
| @@ -825,13 +954,8 @@ func (pm *popupManager) handleLinkMiddleClick( | |||
| 825 | 954 | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 826 | 955 | Msg("middle-click/ctrl+click on link") | |
| 827 | 956 | ||
| 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 { | |
| 835 | 959 | return false | |
| 836 | 960 | } | |
| 837 | 961 | if hooks.getWebView == nil { | |
| @@ -844,11 +968,17 @@ func (pm *popupManager) handleLinkMiddleClick( | |||
| 844 | 968 | 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 | 975 | log.Debug(). | |
| 849 | 976 | Str("decision", string(decision.Kind)). | |
| 850 | 977 | Str("reason", decision.Reason). | |
| 851 | 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 | 982 | if decision.Kind != dto.HostDecisionCreatePane { | |
| 853 | 983 | log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted") | |
| 854 | 984 | return false | |
| @@ -17,20 +17,23 @@ import ( | |||||
| 17 | // adapter layer so popup pane bookkeeping and workspace orchestration do not | 17 | // 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.WebViewFactory | 20 | + | factory port.WebViewFactory |
| 21 | - | popupConfig entity.BrowsingContextConfig | 21 | + | popupConfig entity.BrowsingContextConfig |
| 22 | - | hasPopupConfig bool | 22 | + | hasPopupConfig bool |
| 23 | - | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | 23 | + | onInsertPopup func(ctx context.Context, input InsertPopupInput) error |
| 24 | - | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error | 24 | + | onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error) |
| 25 | - | onClosePane func(ctx context.Context, paneID entity.PaneID) error | 25 | + | onOpenNativePopup func(ctx context.Context, input NativePopupInput) error |
| 26 | - | generatePaneID func() string | 26 | + | 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 browsingContextPolicy | 28 | + | generatePaneID func() string |
| 29 | - | namedContexts *namedBrowsingContextRegistry | 29 | + | windowIDForPane func(entity.PaneID) (string, bool) |
| 30 | - | pendingPopups map[port.WebViewID]*PendingPopup | 30 | + | sourceHostForPane func(entity.PaneID) dto.SourceHostKind |
| 31 | - | popupOAuth map[port.WebViewID]*popupOAuthState | 31 | + | policy browsingContextPolicy |
| 32 | - | popupRefresh map[entity.PaneID]*time.Timer | 32 | + | namedContexts *namedBrowsingContextRegistry |
| 33 | - | mu sync.RWMutex | 33 | + | 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 = fn | 146 | 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 | return | 278 | return | ||
| 232 | } | 279 | } | ||
| 233 | - | name := reusableBrowsingContextName(frameName) | 280 | + | hostWindowID, ok := pm.windowIDForPane(paneID) |
| 234 | - | if name == "" || pm.windowIDForPane == nil { | 281 | + | if !ok { |
| 235 | return | 282 | 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 | return | 300 | 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 parentURIAtOpen | 512 | 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 nil | 618 | 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 := false | 626 | 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.HostDecisionCreatePane | 647 | 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 | break | 659 | 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 false | 959 | return false | ||
| 836 | } | 960 | } | ||
| 837 | if hooks.getWebView == nil { | 961 | if hooks.getWebView == nil { | ||
| @@ -844,11 +968,17 @@ func (pm *popupManager) handleLinkMiddleClick( | |||||
| 844 | return false | 968 | 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 false | 984 | 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 | |||
| 151 | 151 | 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 | 233 | func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |
| 155 | 234 | ctx := context.Background() | |
| 156 | 235 | parentPaneID := entity.PaneID("parent-pane") | |
| @@ -591,7 +670,7 @@ func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t | |||
| 591 | 670 | } | |
| 592 | 671 | c.SetPopupConfig(factory, nil, nil) | |
| 593 | 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 | 674 | c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { | |
| 596 | 675 | insertCalls++ | |
| 597 | 676 | return nil | |
| @@ -151,6 +151,85 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con | |||||
| 151 | return ctx, parentPaneID, parentWV, popupWV, c | 151 | 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 nil | 676 | return nil | ||
M internal/ui/coordinator/tab.go +15 -5
| @@ -35,8 +35,8 @@ type TabCoordinator struct { | |||
| 35 | 35 | onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 36 | 36 | onQuit func() | |
| 37 | 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 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 | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| @@ -132,7 +132,7 @@ func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) { | |||
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 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 | 136 | c.onAttachPopupToTab = fn | |
| 137 | 137 | } | |
| 138 | 138 | ||
| @@ -540,9 +540,19 @@ func (c *TabCoordinator) CreateWithPane( | |||
| 540 | 540 | // Update tab bar visibility | |
| 541 | 541 | c.UpdateBarVisibility(ctx, target) | |
| 542 | 542 | ||
| 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. | |
| 544 | 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 | 558 | // 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 tabs | 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 | 39 | + | 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 = fn | 136 | c.onAttachPopupToTab = fn | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| @@ -540,9 +540,19 @@ func (c *TabCoordinator) CreateWithPane( | |||||
| 540 | // Update tab bar visibility | 540 | // 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 workspace | 543 | + | // 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 view | 558 | // Switch to the new tab's workspace view | ||