detached 8b31f32

refactor(ui): make popup host ownership transactional

commit
8b31f3218560c2190847b9516bc6bfa4a96f68a7
author
brice · · 2026-07-25 07:39 UTC
committer
brice · · 2026-07-26 16:42 UTC
parent
192c70d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +316 -67
M internal/ui/app.go +1 -1
@@ -2948,7 +2948,7 @@ func (a *App) onShutdown(ctx context.Context) {
29482948 popupIDs = append(popupIDs, popupID)
29492949 }
29502950 for _, popupID := range popupIDs {
2951- a.releaseNativePopupWindow(popupID, false, true)
2951+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
29522952 }
29532953 }
29542954 if a.engine != nil {
@@ -2948,7 +2948,7 @@ func (a *App) onShutdown(ctx context.Context) {
2948 popupIDs = append(popupIDs, popupID)2948 popupIDs = append(popupIDs, popupID)
2949 }2949 }
2950 for _, popupID := range popupIDs {2950 for _, popupID := range popupIDs {
2951- a.releaseNativePopupWindow(popupID, false, true)2951+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
2952 }2952 }
2953 }2953 }
2954 if a.engine != nil {2954 if a.engine != nil {
M internal/ui/coordinator/content/popup.go +9 -7
@@ -134,13 +134,15 @@ type StagePopupInput struct {
134134 }
135135
136136 type NativePopupInput struct {
137- ParentPaneID entity.PaneID
138- ParentWebViewID port.WebViewID
139- ParentURIAtOpen string
140- PopupWebView port.WebView
141- TargetURI string
142- Request port.PopupRequest
143- ObserveOAuthAutoClose bool
137+ ParentPaneID entity.PaneID
138+ ParentWebViewID port.WebViewID
139+ ParentURIAtOpen string
140+ PopupWebView port.WebView
141+ TargetURI string
142+ Request port.PopupRequest
143+ ObserveOAuthAutoClose bool
144+ AllowBrowserWindowFallback bool
145+ OnNativeHostAbort func(context.Context, port.WebView) bool
144146 }
145147
146148 // GetBehavior returns the appropriate behavior based on popup type and config.
@@ -134,13 +134,15 @@ type StagePopupInput struct {
134 }134 }
135 135
136 type NativePopupInput struct {136 type NativePopupInput struct {
137- ParentPaneID entity.PaneID137+ ParentPaneID entity.PaneID
138- ParentWebViewID port.WebViewID138+ ParentWebViewID port.WebViewID
139- ParentURIAtOpen string139+ ParentURIAtOpen string
140- PopupWebView port.WebView140+ PopupWebView port.WebView
141- TargetURI string141+ TargetURI string
142- Request port.PopupRequest142+ Request port.PopupRequest
143- ObserveOAuthAutoClose bool143+ ObserveOAuthAutoClose bool
144+ AllowBrowserWindowFallback bool
145+ OnNativeHostAbort func(context.Context, port.WebView) bool
144 }146 }
145 147
146 // GetBehavior returns the appropriate behavior based on popup type and config.148 // GetBehavior returns the appropriate behavior based on popup type and config.
M internal/ui/coordinator/content/popup_hosting.go +41 -0
@@ -59,6 +59,47 @@ func (pm *popupManager) openBrowserWindow(
5959 return popupWV
6060 }
6161
62+// openExistingPopupInBrowserWindow attempts to transfer an already-created
63+// WebView. On failure ownership remains with the caller.
64+func (pm *popupManager) openExistingPopupInBrowserWindow(
65+ ctx context.Context,
66+ hooks popupCoordinatorHooks,
67+ parentPaneID entity.PaneID,
68+ parentWebViewID port.WebViewID,
69+ popupWV port.WebView,
70+ req port.PopupRequest,
71+ decision dto.HostDecision,
72+ ready bool,
73+) bool {
74+ log := logging.FromContext(ctx)
75+ normalized := buildPopupBrowsingContextRequest(req)
76+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
77+ if popupWV == nil || pm.onOpenBrowserWindow == nil {
78+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, nil)
79+ return false
80+ }
81+
82+ paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
83+ if hooks.setupWebViewCallbacks != nil {
84+ hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
85+ }
86+ result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{
87+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane,
88+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready,
89+ })
90+ if err != nil || result.WindowID == "" {
91+ if err == nil {
92+ err = fmt.Errorf("browser window fallback returned an empty window ID")
93+ }
94+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, err)
95+ return false
96+ }
97+ if !req.NoJavaScriptAccess {
98+ pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
99+ }
100+ return true
101+}
102+
62103 func (*popupManager) readyOnCreate(dto.BrowserEngineKind) bool {
63104 // Deferred WebKit requests are staged and routed by a later phase. Every
64105 // immediate route must be visible without depending on another signal.
@@ -59,6 +59,47 @@ func (pm *popupManager) openBrowserWindow(
59 return popupWV59 return popupWV
60 }60 }
61 61
62+// openExistingPopupInBrowserWindow attempts to transfer an already-created
63+// WebView. On failure ownership remains with the caller.
64+func (pm *popupManager) openExistingPopupInBrowserWindow(
65+ ctx context.Context,
66+ hooks popupCoordinatorHooks,
67+ parentPaneID entity.PaneID,
68+ parentWebViewID port.WebViewID,
69+ popupWV port.WebView,
70+ req port.PopupRequest,
71+ decision dto.HostDecision,
72+ ready bool,
73+) bool {
74+ log := logging.FromContext(ctx)
75+ normalized := buildPopupBrowsingContextRequest(req)
76+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
77+ if popupWV == nil || pm.onOpenBrowserWindow == nil {
78+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, nil)
79+ return false
80+ }
81+
82+ paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
83+ if hooks.setupWebViewCallbacks != nil {
84+ hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
85+ }
86+ result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{
87+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane,
88+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready,
89+ })
90+ if err != nil || result.WindowID == "" {
91+ if err == nil {
92+ err = fmt.Errorf("browser window fallback returned an empty window ID")
93+ }
94+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, err)
95+ return false
96+ }
97+ if !req.NoJavaScriptAccess {
98+ pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
99+ }
100+ return true
101+}
102+
62 func (*popupManager) readyOnCreate(dto.BrowserEngineKind) bool {103 func (*popupManager) readyOnCreate(dto.BrowserEngineKind) bool {
63 // Deferred WebKit requests are staged and routed by a later phase. Every104 // Deferred WebKit requests are staged and routed by a later phase. Every
64 // immediate route must be visible without depending on another signal.105 // immediate route must be visible without depending on another signal.
M internal/ui/coordinator/content/popup_manager.go +31 -11
@@ -514,6 +514,7 @@ func (pm *popupManager) popupParentURIAtOpen(
514514
515515 func (pm *popupManager) openNativePopup(
516516 ctx context.Context,
517+ hooks popupCoordinatorHooks,
517518 parentPaneID entity.PaneID,
518519 parentID port.WebViewID,
519520 parentURIAtOpen string,
@@ -521,27 +522,46 @@ func (pm *popupManager) openNativePopup(
521522 decision dto.HostDecision,
522523 ) port.WebView {
523524 log := logging.FromContext(ctx)
525+ normalized := buildPopupBrowsingContextRequest(req)
526+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
524527 if pm.onOpenNativePopup == nil {
525- log.Warn().Msg("native popup host callback not configured")
528+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
526529 return nil
527530 }
528531 popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
529532 if err != nil {
530- log.Error().Err(err).Msg("failed to create webview for native popup")
533+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
531534 return nil
532535 }
533536 cfg := pm.currentPopupConfig()
534537 pm.setBrowsingContextDecision(popupWV, decision)
538+ allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && req.NoJavaScriptAccess
539+ var abortOnce sync.Once
540+ abortResult := false
541+ onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
542+ abortOnce.Do(func() {
543+ if !allowFallback {
544+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
545+ return
546+ }
547+ abortResult = pm.openExistingPopupInBrowserWindow(
548+ abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true,
549+ )
550+ })
551+ return abortResult
552+ }
535553 if err := pm.onOpenNativePopup(ctx, NativePopupInput{
536- ParentPaneID: parentPaneID,
537- ParentWebViewID: parentID,
538- ParentURIAtOpen: parentURIAtOpen,
539- PopupWebView: popupWV,
540- TargetURI: req.TargetURI,
541- Request: req,
542- ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
554+ ParentPaneID: parentPaneID,
555+ ParentWebViewID: parentID,
556+ ParentURIAtOpen: parentURIAtOpen,
557+ PopupWebView: popupWV,
558+ TargetURI: req.TargetURI,
559+ Request: req,
560+ ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
561+ AllowBrowserWindowFallback: allowFallback,
562+ OnNativeHostAbort: onAbort,
543563 }); err != nil {
544- log.Error().Err(err).Msg("failed to open native popup host")
564+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err)
545565 popupWV.Destroy()
546566 return nil
547567 }
@@ -597,7 +617,7 @@ func (pm *popupManager) handlePopupCreate(
597617 case dto.HostDecisionCreateBrowserWindow:
598618 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
599619 case dto.HostDecisionCreateNativePopup:
600- return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
620+ return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
601621 case dto.HostDecisionAwaitPopupFeatures:
602622 logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil)
603623 return nil
@@ -514,6 +514,7 @@ func (pm *popupManager) popupParentURIAtOpen(
514 514
515 func (pm *popupManager) openNativePopup(515 func (pm *popupManager) openNativePopup(
516 ctx context.Context,516 ctx context.Context,
517+ hooks popupCoordinatorHooks,
517 parentPaneID entity.PaneID,518 parentPaneID entity.PaneID,
518 parentID port.WebViewID,519 parentID port.WebViewID,
519 parentURIAtOpen string,520 parentURIAtOpen string,
@@ -521,27 +522,46 @@ func (pm *popupManager) openNativePopup(
521 decision dto.HostDecision,522 decision dto.HostDecision,
522 ) port.WebView {523 ) port.WebView {
523 log := logging.FromContext(ctx)524 log := logging.FromContext(ctx)
525+ normalized := buildPopupBrowsingContextRequest(req)
526+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
524 if pm.onOpenNativePopup == nil {527 if pm.onOpenNativePopup == nil {
525- log.Warn().Msg("native popup host callback not configured")528+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
526 return nil529 return nil
527 }530 }
528 popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)531 popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
529 if err != nil {532 if err != nil {
530- log.Error().Err(err).Msg("failed to create webview for native popup")533+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
531 return nil534 return nil
532 }535 }
533 cfg := pm.currentPopupConfig()536 cfg := pm.currentPopupConfig()
534 pm.setBrowsingContextDecision(popupWV, decision)537 pm.setBrowsingContextDecision(popupWV, decision)
538+ allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && req.NoJavaScriptAccess
539+ var abortOnce sync.Once
540+ abortResult := false
541+ onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
542+ abortOnce.Do(func() {
543+ if !allowFallback {
544+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
545+ return
546+ }
547+ abortResult = pm.openExistingPopupInBrowserWindow(
548+ abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true,
549+ )
550+ })
551+ return abortResult
552+ }
535 if err := pm.onOpenNativePopup(ctx, NativePopupInput{553 if err := pm.onOpenNativePopup(ctx, NativePopupInput{
536- ParentPaneID: parentPaneID,554+ ParentPaneID: parentPaneID,
537- ParentWebViewID: parentID,555+ ParentWebViewID: parentID,
538- ParentURIAtOpen: parentURIAtOpen,556+ ParentURIAtOpen: parentURIAtOpen,
539- PopupWebView: popupWV,557+ PopupWebView: popupWV,
540- TargetURI: req.TargetURI,558+ TargetURI: req.TargetURI,
541- Request: req,559+ Request: req,
542- ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),560+ ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
561+ AllowBrowserWindowFallback: allowFallback,
562+ OnNativeHostAbort: onAbort,
543 }); err != nil {563 }); err != nil {
544- log.Error().Err(err).Msg("failed to open native popup host")564+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err)
545 popupWV.Destroy()565 popupWV.Destroy()
546 return nil566 return nil
547 }567 }
@@ -597,7 +617,7 @@ func (pm *popupManager) handlePopupCreate(
597 case dto.HostDecisionCreateBrowserWindow:617 case dto.HostDecisionCreateBrowserWindow:
598 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))618 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
599 case dto.HostDecisionCreateNativePopup:619 case dto.HostDecisionCreateNativePopup:
600- return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)620+ return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
601 case dto.HostDecisionAwaitPopupFeatures:621 case dto.HostDecisionAwaitPopupFeatures:
602 logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil)622 logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostUnavailable, nil)
603 return nil623 return nil
M internal/ui/coordinator/content/popup_test.go +105 -0
@@ -644,6 +644,111 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
644644 assert.Equal(t, 0, insertCalls)
645645 }
646646
647+func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) {
648+ ctx := context.Background()
649+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
650+ popupWV.EXPECT().ID().Return(port.WebViewID(701)).Once()
651+ factory := mocks.NewMockWebViewFactory(t)
652+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
653+
654+ pm := newPopupManager()
655+ pm.setConfig(factory, nil, nil)
656+ browserCalls := 0
657+ pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
658+ browserCalls++
659+ assert.Same(t, popupWV, input.PopupWebView)
660+ assert.True(t, input.Ready)
661+ return BrowserWindowResult{WindowID: "fallback-window"}, nil
662+ })
663+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
664+ assert.True(t, input.AllowBrowserWindowFallback)
665+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
666+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
667+ return nil
668+ })
669+
670+ got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
671+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
672+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
673+ require.Same(t, popupWV, got)
674+ assert.Equal(t, 1, browserCalls)
675+}
676+
677+func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) {
678+ for _, tc := range []struct {
679+ name string
680+ target string
681+ decision dto.HostDecision
682+ }{
683+ {name: "auth", target: "https://accounts.google.com/o/oauth2/auth", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}},
684+ {name: "required opener", target: "https://example.com/opener", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true}},
685+ } {
686+ t.Run(tc.name, func(t *testing.T) {
687+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
688+ popupWV.EXPECT().Destroy().Once()
689+ factory := mocks.NewMockWebViewFactory(t)
690+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
691+ pm := newPopupManager()
692+ pm.setConfig(factory, nil, nil)
693+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
694+ t.Fatal("auth/opener-required popup must not degrade to browser fallback")
695+ return BrowserWindowResult{}, nil
696+ })
697+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
698+ assert.False(t, input.AllowBrowserWindowFallback)
699+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
700+ input.PopupWebView.Destroy()
701+ }
702+ return nil
703+ })
704+
705+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
706+ TargetURI: tc.target, NoJavaScriptAccess: true,
707+ }, tc.decision)
708+ require.Same(t, popupWV, got)
709+ })
710+ }
711+}
712+
713+func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) {
714+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
715+ popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once()
716+ popupWV.EXPECT().Destroy().Once()
717+ factory := mocks.NewMockWebViewFactory(t)
718+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
719+ pm := newPopupManager()
720+ pm.setConfig(factory, nil, nil)
721+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
722+ return BrowserWindowResult{}, errors.New("fallback failed")
723+ })
724+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
725+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
726+ input.PopupWebView.Destroy()
727+ }
728+ return nil
729+ })
730+
731+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
732+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
733+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
734+ require.Same(t, popupWV, got)
735+}
736+
737+func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) {
738+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
739+ popupWV.EXPECT().Destroy().Once()
740+ factory := mocks.NewMockWebViewFactory(t)
741+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
742+ pm := newPopupManager()
743+ pm.setConfig(factory, nil, nil)
744+ pm.setOnOpenNativePopup(func(context.Context, NativePopupInput) error { return errors.New("setup failed") })
745+
746+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
747+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
748+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
749+ assert.Nil(t, got)
750+}
751+
647752 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
648753 ctx := context.Background()
649754 parentPaneID := entity.PaneID("parent-pane")
@@ -644,6 +644,111 @@ func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t
644 assert.Equal(t, 0, insertCalls)644 assert.Equal(t, 0, insertCalls)
645 }645 }
646 646
647+func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) {
648+ ctx := context.Background()
649+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
650+ popupWV.EXPECT().ID().Return(port.WebViewID(701)).Once()
651+ factory := mocks.NewMockWebViewFactory(t)
652+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
653+
654+ pm := newPopupManager()
655+ pm.setConfig(factory, nil, nil)
656+ browserCalls := 0
657+ pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
658+ browserCalls++
659+ assert.Same(t, popupWV, input.PopupWebView)
660+ assert.True(t, input.Ready)
661+ return BrowserWindowResult{WindowID: "fallback-window"}, nil
662+ })
663+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
664+ assert.True(t, input.AllowBrowserWindowFallback)
665+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
666+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
667+ return nil
668+ })
669+
670+ got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
671+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
672+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
673+ require.Same(t, popupWV, got)
674+ assert.Equal(t, 1, browserCalls)
675+}
676+
677+func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) {
678+ for _, tc := range []struct {
679+ name string
680+ target string
681+ decision dto.HostDecision
682+ }{
683+ {name: "auth", target: "https://accounts.google.com/o/oauth2/auth", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}},
684+ {name: "required opener", target: "https://example.com/opener", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true}},
685+ } {
686+ t.Run(tc.name, func(t *testing.T) {
687+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
688+ popupWV.EXPECT().Destroy().Once()
689+ factory := mocks.NewMockWebViewFactory(t)
690+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
691+ pm := newPopupManager()
692+ pm.setConfig(factory, nil, nil)
693+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
694+ t.Fatal("auth/opener-required popup must not degrade to browser fallback")
695+ return BrowserWindowResult{}, nil
696+ })
697+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
698+ assert.False(t, input.AllowBrowserWindowFallback)
699+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
700+ input.PopupWebView.Destroy()
701+ }
702+ return nil
703+ })
704+
705+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
706+ TargetURI: tc.target, NoJavaScriptAccess: true,
707+ }, tc.decision)
708+ require.Same(t, popupWV, got)
709+ })
710+ }
711+}
712+
713+func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) {
714+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
715+ popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once()
716+ popupWV.EXPECT().Destroy().Once()
717+ factory := mocks.NewMockWebViewFactory(t)
718+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
719+ pm := newPopupManager()
720+ pm.setConfig(factory, nil, nil)
721+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
722+ return BrowserWindowResult{}, errors.New("fallback failed")
723+ })
724+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
725+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
726+ input.PopupWebView.Destroy()
727+ }
728+ return nil
729+ })
730+
731+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
732+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
733+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
734+ require.Same(t, popupWV, got)
735+}
736+
737+func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) {
738+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
739+ popupWV.EXPECT().Destroy().Once()
740+ factory := mocks.NewMockWebViewFactory(t)
741+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
742+ pm := newPopupManager()
743+ pm.setConfig(factory, nil, nil)
744+ pm.setOnOpenNativePopup(func(context.Context, NativePopupInput) error { return errors.New("setup failed") })
745+
746+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
747+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
748+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
749+ assert.Nil(t, got)
750+}
751+
647 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {752 func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
648 ctx := context.Background()753 ctx := context.Background()
649 parentPaneID := entity.PaneID("parent-pane")754 parentPaneID := entity.PaneID("parent-pane")
M internal/ui/native_popup_window.go +50 -25
@@ -14,25 +14,32 @@ import (
1414 "github.com/bnema/puregotk/v4/gtk"
1515 )
1616
17+type nativePopupShell interface {
18+ DetachContent() *gtk.Widget
19+ Destroy()
20+ Close()
21+ Show()
22+}
23+
1724 type nativePopupWindow struct {
1825 popupID port.WebViewID
1926 parentPaneID entity.PaneID
2027 parentWindowID string
21- popupWindow *window.PopupWindow
28+ popupWindow nativePopupShell
2229 webView port.WebView
2330 closeOnce sync.Once
2431 }
2532
26-type nativePopupDestroyer interface {
27- Destroy()
28-}
33+type nativePopupReleaseMode int
34+
35+const (
36+ nativePopupReleaseDestroy nativePopupReleaseMode = iota
37+ nativePopupReleaseDetach
38+)
2939
3040 var newPopupWindow = window.NewPopup
3141
32-func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {
33- if wv != nil && !wv.IsDestroyed() {
34- wv.Destroy()
35- }
42+func destroyFailedNativePopupSetup(popupShell nativePopupShell) {
3643 if popupShell != nil {
3744 popupShell.Destroy()
3845 }
@@ -67,17 +74,16 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
6774
6875 popupShell, err := newPopupWindow(ctx, a.gtkApp)
6976 if err != nil {
70- destroyFailedNativePopupSetup(nil, input.PopupWebView)
7177 return err
7278 }
7379 if a.contentCoord == nil {
74- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
80+ destroyFailedNativePopupSetup(popupShell)
7581 return fmt.Errorf("content coordinator not available for native popup")
7682 }
7783 widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
7884 gtkWidget, err := prepareNativePopupContentWidget(widget)
7985 if err != nil {
80- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)
86+ destroyFailedNativePopupSetup(popupShell)
8187 return err
8288 }
8389 popupShell.SetContent(gtkWidget)
@@ -102,7 +108,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
102108
103109 if popupShell.Window() != nil {
104110 closeRequestCb := func(_ gtk.Window) bool {
105- a.releaseNativePopupWindow(popupID, false, false)
111+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
106112 return false
107113 }
108114 popupShell.Window().ConnectCloseRequest(&closeRequestCb)
@@ -111,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
111117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
112118 aborter.SetNativePopupHostAbort(func() {
113119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
114- a.releaseNativePopupWindow(popupID, true, false)
120+ a.abortNativePopupWindow(ctx, popupID, input)
115121 })
116122 })
117123 }
@@ -123,7 +129,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
123129 })
124130 lifecycle.SetOnClose(func() {
125131 a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {
126- a.releaseNativePopupWindow(popupID, true, false)
132+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
127133 })
128134 })
129135 } else {
@@ -132,7 +138,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
132138 if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
133139 oauthWV.AddCloseCallback(func() {
134140 a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {
135- a.releaseNativePopupWindow(popupID, true, false)
141+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
136142 })
137143 })
138144 }
@@ -148,6 +154,22 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
148154 return nil
149155 }
150156
157+func (a *App) abortNativePopupWindow(ctx context.Context, popupID port.WebViewID, input content.NativePopupInput) {
158+ wv := a.releaseNativePopupWindow(popupID, nativePopupReleaseDetach)
159+ if wv == nil {
160+ return
161+ }
162+ transferred := false
163+ if input.AllowBrowserWindowFallback && input.OnNativeHostAbort != nil {
164+ transferred = input.OnNativeHostAbort(ctx, wv)
165+ } else if input.OnNativeHostAbort != nil {
166+ input.OnNativeHostAbort(ctx, wv)
167+ }
168+ if !transferred && !wv.IsDestroyed() {
169+ wv.Destroy()
170+ }
171+}
172+
151173 func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {
152174 if a == nil || fn == nil {
153175 return
@@ -181,26 +203,29 @@ func (a *App) showNativePopupWindow(popupID port.WebViewID) {
181203 }
182204 }
183205
184-func (a *App) releaseNativePopupWindow(popupID port.WebViewID, closeWindow, destroyWindow bool) {
206+func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView {
185207 if a == nil || a.nativePopupWindows == nil {
186- return
208+ return nil
187209 }
188210 state := a.nativePopupWindows[popupID]
189211 if state == nil {
190- return
212+ return nil
191213 }
214+ var detached port.WebView
192215 state.closeOnce.Do(func() {
193216 delete(a.nativePopupWindows, popupID)
194- if state.webView != nil && !state.webView.IsDestroyed() {
217+ if mode == nativePopupReleaseDetach {
218+ if state.popupWindow != nil && state.popupWindow.DetachContent() != nil {
219+ detached = state.webView
220+ } else if state.webView != nil && !state.webView.IsDestroyed() {
221+ state.webView.Destroy()
222+ }
223+ } else if state.webView != nil && !state.webView.IsDestroyed() {
195224 state.webView.Destroy()
196225 }
197226 if state.popupWindow != nil {
198- switch {
199- case destroyWindow:
200- state.popupWindow.Destroy()
201- case closeWindow:
202- state.popupWindow.Close()
203- }
227+ state.popupWindow.Destroy()
204228 }
205229 })
230+ return detached
206231 }
@@ -14,25 +14,32 @@ import (
14 "github.com/bnema/puregotk/v4/gtk"14 "github.com/bnema/puregotk/v4/gtk"
15 )15 )
16 16
17+type nativePopupShell interface {
18+ DetachContent() *gtk.Widget
19+ Destroy()
20+ Close()
21+ Show()
22+}
23+
17 type nativePopupWindow struct {24 type nativePopupWindow struct {
18 popupID port.WebViewID25 popupID port.WebViewID
19 parentPaneID entity.PaneID26 parentPaneID entity.PaneID
20 parentWindowID string27 parentWindowID string
21- popupWindow *window.PopupWindow28+ popupWindow nativePopupShell
22 webView port.WebView29 webView port.WebView
23 closeOnce sync.Once30 closeOnce sync.Once
24 }31 }
25 32
26-type nativePopupDestroyer interface {33+type nativePopupReleaseMode int
27- Destroy()34+
28-}35+const (
36+ nativePopupReleaseDestroy nativePopupReleaseMode = iota
37+ nativePopupReleaseDetach
38+)
29 39
30 var newPopupWindow = window.NewPopup40 var newPopupWindow = window.NewPopup
31 41
32-func destroyFailedNativePopupSetup(popupShell nativePopupDestroyer, wv port.WebView) {42+func destroyFailedNativePopupSetup(popupShell nativePopupShell) {
33- if wv != nil && !wv.IsDestroyed() {
34- wv.Destroy()
35- }
36 if popupShell != nil {43 if popupShell != nil {
37 popupShell.Destroy()44 popupShell.Destroy()
38 }45 }
@@ -67,17 +74,16 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
67 74
68 popupShell, err := newPopupWindow(ctx, a.gtkApp)75 popupShell, err := newPopupWindow(ctx, a.gtkApp)
69 if err != nil {76 if err != nil {
70- destroyFailedNativePopupSetup(nil, input.PopupWebView)
71 return err77 return err
72 }78 }
73 if a.contentCoord == nil {79 if a.contentCoord == nil {
74- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)80+ destroyFailedNativePopupSetup(popupShell)
75 return fmt.Errorf("content coordinator not available for native popup")81 return fmt.Errorf("content coordinator not available for native popup")
76 }82 }
77 widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)83 widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
78 gtkWidget, err := prepareNativePopupContentWidget(widget)84 gtkWidget, err := prepareNativePopupContentWidget(widget)
79 if err != nil {85 if err != nil {
80- destroyFailedNativePopupSetup(popupShell, input.PopupWebView)86+ destroyFailedNativePopupSetup(popupShell)
81 return err87 return err
82 }88 }
83 popupShell.SetContent(gtkWidget)89 popupShell.SetContent(gtkWidget)
@@ -102,7 +108,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
102 108
103 if popupShell.Window() != nil {109 if popupShell.Window() != nil {
104 closeRequestCb := func(_ gtk.Window) bool {110 closeRequestCb := func(_ gtk.Window) bool {
105- a.releaseNativePopupWindow(popupID, false, false)111+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
106 return false112 return false
107 }113 }
108 popupShell.Window().ConnectCloseRequest(&closeRequestCb)114 popupShell.Window().ConnectCloseRequest(&closeRequestCb)
@@ -111,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
111 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
112 aborter.SetNativePopupHostAbort(func() {118 aborter.SetNativePopupHostAbort(func() {
113 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
114- a.releaseNativePopupWindow(popupID, true, false)120+ a.abortNativePopupWindow(ctx, popupID, input)
115 })121 })
116 })122 })
117 }123 }
@@ -123,7 +129,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
123 })129 })
124 lifecycle.SetOnClose(func() {130 lifecycle.SetOnClose(func() {
125 a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {131 a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {
126- a.releaseNativePopupWindow(popupID, true, false)132+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
127 })133 })
128 })134 })
129 } else {135 } else {
@@ -132,7 +138,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
132 if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {138 if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
133 oauthWV.AddCloseCallback(func() {139 oauthWV.AddCloseCallback(func() {
134 a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {140 a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {
135- a.releaseNativePopupWindow(popupID, true, false)141+ a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
136 })142 })
137 })143 })
138 }144 }
@@ -148,6 +154,22 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
148 return nil154 return nil
149 }155 }
150 156
157+func (a *App) abortNativePopupWindow(ctx context.Context, popupID port.WebViewID, input content.NativePopupInput) {
158+ wv := a.releaseNativePopupWindow(popupID, nativePopupReleaseDetach)
159+ if wv == nil {
160+ return
161+ }
162+ transferred := false
163+ if input.AllowBrowserWindowFallback && input.OnNativeHostAbort != nil {
164+ transferred = input.OnNativeHostAbort(ctx, wv)
165+ } else if input.OnNativeHostAbort != nil {
166+ input.OnNativeHostAbort(ctx, wv)
167+ }
168+ if !transferred && !wv.IsDestroyed() {
169+ wv.Destroy()
170+ }
171+}
172+
151 func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {173 func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {
152 if a == nil || fn == nil {174 if a == nil || fn == nil {
153 return175 return
@@ -181,26 +203,29 @@ func (a *App) showNativePopupWindow(popupID port.WebViewID) {
181 }203 }
182 }204 }
183 205
184-func (a *App) releaseNativePopupWindow(popupID port.WebViewID, closeWindow, destroyWindow bool) {206+func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView {
185 if a == nil || a.nativePopupWindows == nil {207 if a == nil || a.nativePopupWindows == nil {
186- return208+ return nil
187 }209 }
188 state := a.nativePopupWindows[popupID]210 state := a.nativePopupWindows[popupID]
189 if state == nil {211 if state == nil {
190- return212+ return nil
191 }213 }
214+ var detached port.WebView
192 state.closeOnce.Do(func() {215 state.closeOnce.Do(func() {
193 delete(a.nativePopupWindows, popupID)216 delete(a.nativePopupWindows, popupID)
194- if state.webView != nil && !state.webView.IsDestroyed() {217+ if mode == nativePopupReleaseDetach {
218+ if state.popupWindow != nil && state.popupWindow.DetachContent() != nil {
219+ detached = state.webView
220+ } else if state.webView != nil && !state.webView.IsDestroyed() {
221+ state.webView.Destroy()
222+ }
223+ } else if state.webView != nil && !state.webView.IsDestroyed() {
195 state.webView.Destroy()224 state.webView.Destroy()
196 }225 }
197 if state.popupWindow != nil {226 if state.popupWindow != nil {
198- switch {227+ state.popupWindow.Destroy()
199- case destroyWindow:
200- state.popupWindow.Destroy()
201- case closeWindow:
202- state.popupWindow.Close()
203- }
204 }228 }
205 })229 })
230+ return detached
206 }231 }
M internal/ui/native_popup_window_test.go +67 -23
@@ -18,12 +18,16 @@ import (
1818 )
1919
2020 type popupDestroySpy struct {
21- destroyed bool
21+ destroyed int
22+ detached int
23+ closed int
24+ shown int
2225 }
2326
24-func (p *popupDestroySpy) Destroy() {
25- p.destroyed = true
26-}
27+func (p *popupDestroySpy) Destroy() { p.destroyed++ }
28+func (p *popupDestroySpy) DetachContent() *gtk.Widget { p.detached++; return &gtk.Widget{} }
29+func (p *popupDestroySpy) Close() { p.closed++ }
30+func (p *popupDestroySpy) Show() { p.shown++ }
2731
2832 func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) {
2933 widget := layoutmocks.NewMockWidget(t)
@@ -46,7 +50,7 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te
4650 assert.Nil(t, got)
4751 }
4852
49-func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {
53+func TestOpenNativePopupWindowLeavesWebViewOwnedByCallerOnSetupFailure(t *testing.T) {
5054 oldNewPopupWindow := newPopupWindow
5155 t.Cleanup(func() {
5256 newPopupWindow = oldNewPopupWindow
@@ -58,23 +62,15 @@ func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *tes
5862 }
5963
6064 wv := portmocks.NewMockWebView(t)
61- wv.EXPECT().IsDestroyed().Return(false).Once()
62- wv.EXPECT().Destroy().Once()
63-
6465 app := &App{gtkApp: &gtk.Application{}}
6566 err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
6667 require.ErrorIs(t, err, expectedErr)
6768 }
6869
69-func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {
70- wv := portmocks.NewMockWebView(t)
71- wv.EXPECT().IsDestroyed().Return(false).Once()
72- wv.EXPECT().Destroy().Once()
73-
70+func TestDestroyFailedNativePopupSetupDestroysOnlyShell(t *testing.T) {
7471 shell := &popupDestroySpy{}
75- destroyFailedNativePopupSetup(shell, wv)
76-
77- assert.True(t, shell.destroyed)
72+ destroyFailedNativePopupSetup(shell)
73+ assert.Equal(t, 1, shell.destroyed)
7874 }
7975
8076 func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) {
@@ -104,19 +100,67 @@ func TestDispatchNativePopupLifecycleTreatsCompletedAfterTimeoutAsCompleted(t *t
104100 assert.True(t, ran)
105101 }
106102
107-func TestReleaseNativePopupWindow_DestroysWebViewAndRemovesState(t *testing.T) {
103+func TestDetachNativePopupWindowRemovesWidgetWithoutDestroyingWebView(t *testing.T) {
104+ wv := portmocks.NewMockWebView(t)
105+ shell := &popupDestroySpy{}
106+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
107+ 1: {popupID: 1, webView: wv, popupWindow: shell},
108+ }}
109+
110+ detached := app.releaseNativePopupWindow(1, nativePopupReleaseDetach)
111+ assert.Same(t, wv, detached)
112+ assert.Equal(t, 1, shell.detached)
113+ assert.Equal(t, 1, shell.destroyed)
114+}
115+
116+func TestNativePopupAbortTransfersEligibleWebViewToBrowserFallback(t *testing.T) {
117+ wv := portmocks.NewMockWebView(t)
118+ shell := &popupDestroySpy{}
119+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
120+ 1: {popupID: 1, webView: wv, popupWindow: shell},
121+ }}
122+ callbackCalls := 0
123+ app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{
124+ AllowBrowserWindowFallback: true,
125+ OnNativeHostAbort: func(_ context.Context, got port.WebView) bool {
126+ callbackCalls++
127+ assert.Same(t, wv, got)
128+ assert.Equal(t, 1, shell.detached, "widget must detach before fallback adoption")
129+ return true
130+ },
131+ })
132+
133+ assert.Equal(t, 1, callbackCalls)
134+ assert.Equal(t, 1, shell.destroyed)
135+}
136+
137+func TestNativePopupAbortDestroysOpenerRequiredWebView(t *testing.T) {
108138 wv := portmocks.NewMockWebView(t)
109139 wv.EXPECT().IsDestroyed().Return(false).Once()
110140 wv.EXPECT().Destroy().Once()
141+ shell := &popupDestroySpy{}
142+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
143+ 1: {popupID: 1, webView: wv, popupWindow: shell},
144+ }}
145+ app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{
146+ AllowBrowserWindowFallback: false,
147+ OnNativeHostAbort: func(context.Context, port.WebView) bool { return false },
148+ })
149+ assert.Equal(t, 1, shell.detached)
150+}
111151
152+func TestReleaseNativePopupWindowDestroysOnlyOnce(t *testing.T) {
153+ wv := portmocks.NewMockWebView(t)
154+ wv.EXPECT().IsDestroyed().Return(false).Once()
155+ wv.EXPECT().Destroy().Once()
156+ shell := &popupDestroySpy{}
112157 app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
113- port.WebViewID(1): {
114- popupID: port.WebViewID(1),
115- webView: wv,
116- },
158+ 1: {popupID: 1, webView: wv, popupWindow: shell},
117159 }}
118160
119- app.releaseNativePopupWindow(port.WebViewID(1), false, false)
120- _, ok := app.nativePopupWindows[port.WebViewID(1)]
161+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
162+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
163+ _, ok := app.nativePopupWindows[1]
121164 assert.False(t, ok)
165+ assert.Equal(t, 1, shell.destroyed)
122166 }
@@ -18,12 +18,16 @@ import (
18 )18 )
19 19
20 type popupDestroySpy struct {20 type popupDestroySpy struct {
21- destroyed bool21+ destroyed int
22+ detached int
23+ closed int
24+ shown int
22 }25 }
23 26
24-func (p *popupDestroySpy) Destroy() {27+func (p *popupDestroySpy) Destroy() { p.destroyed++ }
25- p.destroyed = true28+func (p *popupDestroySpy) DetachContent() *gtk.Widget { p.detached++; return &gtk.Widget{} }
26-}29+func (p *popupDestroySpy) Close() { p.closed++ }
30+func (p *popupDestroySpy) Show() { p.shown++ }
27 31
28 func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) {32 func TestPrepareNativePopupContentWidget_ExpandsWrappedWidget(t *testing.T) {
29 widget := layoutmocks.NewMockWidget(t)33 widget := layoutmocks.NewMockWidget(t)
@@ -46,7 +50,7 @@ func TestPrepareNativePopupContentWidget_ErrorsWhenWrappedGTKWidgetMissing(t *te
46 assert.Nil(t, got)50 assert.Nil(t, got)
47 }51 }
48 52
49-func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *testing.T) {53+func TestOpenNativePopupWindowLeavesWebViewOwnedByCallerOnSetupFailure(t *testing.T) {
50 oldNewPopupWindow := newPopupWindow54 oldNewPopupWindow := newPopupWindow
51 t.Cleanup(func() {55 t.Cleanup(func() {
52 newPopupWindow = oldNewPopupWindow56 newPopupWindow = oldNewPopupWindow
@@ -58,23 +62,15 @@ func TestOpenNativePopupWindow_DestroysWebViewWhenPopupShellCreationFails(t *tes
58 }62 }
59 63
60 wv := portmocks.NewMockWebView(t)64 wv := portmocks.NewMockWebView(t)
61- wv.EXPECT().IsDestroyed().Return(false).Once()
62- wv.EXPECT().Destroy().Once()
63-
64 app := &App{gtkApp: &gtk.Application{}}65 app := &App{gtkApp: &gtk.Application{}}
65 err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})66 err := app.openNativePopupWindow(context.Background(), content.NativePopupInput{PopupWebView: wv})
66 require.ErrorIs(t, err, expectedErr)67 require.ErrorIs(t, err, expectedErr)
67 }68 }
68 69
69-func TestDestroyFailedNativePopupSetup_DestroysWebViewAndShell(t *testing.T) {70+func TestDestroyFailedNativePopupSetupDestroysOnlyShell(t *testing.T) {
70- wv := portmocks.NewMockWebView(t)
71- wv.EXPECT().IsDestroyed().Return(false).Once()
72- wv.EXPECT().Destroy().Once()
73-
74 shell := &popupDestroySpy{}71 shell := &popupDestroySpy{}
75- destroyFailedNativePopupSetup(shell, wv)72+ destroyFailedNativePopupSetup(shell)
76-73+ assert.Equal(t, 1, shell.destroyed)
77- assert.True(t, shell.destroyed)
78 }74 }
79 75
80 func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) {76 func TestDispatchNativePopupLifecycleSkipsWorkWhenDispatchTimesOutBeforeStart(t *testing.T) {
@@ -104,19 +100,67 @@ func TestDispatchNativePopupLifecycleTreatsCompletedAfterTimeoutAsCompleted(t *t
104 assert.True(t, ran)100 assert.True(t, ran)
105 }101 }
106 102
107-func TestReleaseNativePopupWindow_DestroysWebViewAndRemovesState(t *testing.T) {103+func TestDetachNativePopupWindowRemovesWidgetWithoutDestroyingWebView(t *testing.T) {
104+ wv := portmocks.NewMockWebView(t)
105+ shell := &popupDestroySpy{}
106+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
107+ 1: {popupID: 1, webView: wv, popupWindow: shell},
108+ }}
109+
110+ detached := app.releaseNativePopupWindow(1, nativePopupReleaseDetach)
111+ assert.Same(t, wv, detached)
112+ assert.Equal(t, 1, shell.detached)
113+ assert.Equal(t, 1, shell.destroyed)
114+}
115+
116+func TestNativePopupAbortTransfersEligibleWebViewToBrowserFallback(t *testing.T) {
117+ wv := portmocks.NewMockWebView(t)
118+ shell := &popupDestroySpy{}
119+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
120+ 1: {popupID: 1, webView: wv, popupWindow: shell},
121+ }}
122+ callbackCalls := 0
123+ app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{
124+ AllowBrowserWindowFallback: true,
125+ OnNativeHostAbort: func(_ context.Context, got port.WebView) bool {
126+ callbackCalls++
127+ assert.Same(t, wv, got)
128+ assert.Equal(t, 1, shell.detached, "widget must detach before fallback adoption")
129+ return true
130+ },
131+ })
132+
133+ assert.Equal(t, 1, callbackCalls)
134+ assert.Equal(t, 1, shell.destroyed)
135+}
136+
137+func TestNativePopupAbortDestroysOpenerRequiredWebView(t *testing.T) {
108 wv := portmocks.NewMockWebView(t)138 wv := portmocks.NewMockWebView(t)
109 wv.EXPECT().IsDestroyed().Return(false).Once()139 wv.EXPECT().IsDestroyed().Return(false).Once()
110 wv.EXPECT().Destroy().Once()140 wv.EXPECT().Destroy().Once()
141+ shell := &popupDestroySpy{}
142+ app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
143+ 1: {popupID: 1, webView: wv, popupWindow: shell},
144+ }}
145+ app.abortNativePopupWindow(context.Background(), 1, content.NativePopupInput{
146+ AllowBrowserWindowFallback: false,
147+ OnNativeHostAbort: func(context.Context, port.WebView) bool { return false },
148+ })
149+ assert.Equal(t, 1, shell.detached)
150+}
111 151
152+func TestReleaseNativePopupWindowDestroysOnlyOnce(t *testing.T) {
153+ wv := portmocks.NewMockWebView(t)
154+ wv.EXPECT().IsDestroyed().Return(false).Once()
155+ wv.EXPECT().Destroy().Once()
156+ shell := &popupDestroySpy{}
112 app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{157 app := &App{nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
113- port.WebViewID(1): {158+ 1: {popupID: 1, webView: wv, popupWindow: shell},
114- popupID: port.WebViewID(1),
115- webView: wv,
116- },
117 }}159 }}
118 160
119- app.releaseNativePopupWindow(port.WebViewID(1), false, false)161+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
120- _, ok := app.nativePopupWindows[port.WebViewID(1)]162+ app.releaseNativePopupWindow(1, nativePopupReleaseDestroy)
163+ _, ok := app.nativePopupWindows[1]
121 assert.False(t, ok)164 assert.False(t, ok)
165+ assert.Equal(t, 1, shell.destroyed)
122 }166 }
M internal/ui/window/popup_window.go +12 -0
@@ -61,6 +61,18 @@ func (pw *PopupWindow) SetContent(widget *gtk.Widget) {
6161 pw.currentContent = widget
6262 }
6363
64+// DetachContent removes and returns the hosted widget without destroying it.
65+// Callers must detach before reparenting the widget into another GTK host.
66+func (pw *PopupWindow) DetachContent() *gtk.Widget {
67+ if pw == nil || pw.content == nil || pw.currentContent == nil {
68+ return nil
69+ }
70+ widget := pw.currentContent
71+ pw.content.Remove(widget)
72+ pw.currentContent = nil
73+ return widget
74+}
75+
6476 func (pw *PopupWindow) Show() {
6577 if pw == nil || pw.window == nil {
6678 return
@@ -61,6 +61,18 @@ func (pw *PopupWindow) SetContent(widget *gtk.Widget) {
61 pw.currentContent = widget61 pw.currentContent = widget
62 }62 }
63 63
64+// DetachContent removes and returns the hosted widget without destroying it.
65+// Callers must detach before reparenting the widget into another GTK host.
66+func (pw *PopupWindow) DetachContent() *gtk.Widget {
67+ if pw == nil || pw.content == nil || pw.currentContent == nil {
68+ return nil
69+ }
70+ widget := pw.currentContent
71+ pw.content.Remove(widget)
72+ pw.currentContent = nil
73+ return widget
74+}
75+
64 func (pw *PopupWindow) Show() {76 func (pw *PopupWindow) Show() {
65 if pw == nil || pw.window == nil {77 if pw == nil || pw.window == nil {
66 return78 return

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close